From 11fb9f15ad4ae2c1c6d8366a36f4937e9203379e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Thu, 1 Oct 2026 21:43:43 +0200 Subject: [PATCH 01/22] An assistant drawer: a question becomes a Web-Algebra plan, shown before it runs The drawer on the right edge of every page for a signed-in reader sends a question in natural language to the web-algebra sidecar's plan service, with the document the reader is on, its dataspace's SPARQL endpoint and the earlier turns. The plan comes back as its summary, the operations it invokes and the XML itself, and runs only on Execute (or at once with "Execute by default"); the steps report as the executor does, the result set or the documents written are shown, and the page catches up. Each card keeps its plan and, once run, what it returned, so a follow-up that refers to "them" is planned on that result as a VALUES block rather than on a new query. A Clear button empties the log and both stores. LDH's operation family is addressed as `waldh:` elements. The stack gains the web-algebra service and nginx's reserved /webalgebra path with the caller's certificate; OPENAI_MODEL picks the planner's model. The chat section of ldh.css parks the core Drawer under the sticky header. Co-Authored-By: Claude Fable 5.1 --- .env_sample | 5 + CHANGELOG.md | 4 + CLAUDE.md | 1 + README.md | 3 + docker-compose.yml | 63 ++ .../com/atomgraph/linkeddatahub/css/ldh.css | 76 ++ .../atomgraph/linkeddatahub/xsl/client.xsl | 3 + .../linkeddatahub/xsl/client/chat.xsl | 876 ++++++++++++++++++ .../linkeddatahub/xsl/client/navigation.xsl | 7 +- .../atomgraph/linkeddatahub/xsl/layout.xsl | 75 ++ .../linkeddatahub/xsl/translations.rdf | 64 ++ tests/ui/coverage/components.mjs | 3 + tests/ui/specs/shell/assistant.spec.mjs | 164 ++++ 13 files changed, 1341 insertions(+), 3 deletions(-) create mode 100644 src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/chat.xsl create mode 100644 tests/ui/specs/shell/assistant.spec.mjs diff --git a/.env_sample b/.env_sample index 6738d3a56..591bd0679 100644 --- a/.env_sample +++ b/.env_sample @@ -17,3 +17,8 @@ OWNER_STATE_OR_PROVINCE=Denmark OWNER_COUNTRY_NAME=DK MAX_CONTENT_LENGTH=2097152 + +# the assistant's plan service image tag (ghcr.io/atomgraph/webalgebra-server-ldh); `local` for an image built from a REST-VKG checkout +# WEBALGEBRA_VERSION=latest +# the OpenAI model the assistant plans and writes queries with; gpt-4o when unset +# OPENAI_MODEL=gpt-4o diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d400d606..b1f1b845b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ ## [Unreleased] +### Added +- An assistant drawer on the right edge of every page for a signed-in reader: a request in natural language becomes a Web-Algebra plan, shown with its operations and its XML, and runs only on Execute; the steps and the documents the plan wrote are reported as they happen, and the page reloads what changed +- A `web-algebra` service in the stack (`ghcr.io/atomgraph/webalgebra-server-ldh`), reached through nginx at the reserved `/webalgebra` path with a WebID certificate on the connection; it acts for the reader through the secretary agent's delegation, so a plan may write exactly what its reader may. Needs the `openai_api_key` secret + ### Changed - `MAX_CONN_PER_ROUTE`, `MAX_TOTAL_CONN` and `MAX_REQUEST_RETRIES` reach the platform as system properties, like the other HTTP client settings, rather than as `ROOT.xml` context parameters; the `ldhc:` context parameters are still read when no system property is set - The browser parses and serialises SPARQL with SPARQL.js alone: the last SPARQLBuilder calls were `fromString().build()` and `fromQuery().toString()`, which are its `Parser` and `Generator` with nothing added, so `SPARQLBuilder.js` and its second copy of SPARQL.js (330 KB) no longer ship with the page diff --git a/CLAUDE.md b/CLAUDE.md index c2e17250f..94867e5ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,7 @@ The application runs as a multi-container setup: - **linkeddatahub**: Main Java application (Tomcat) - **fuseki**: One SPARQL server holding a TDB2 dataset per dataspace role (`config/fuseki/config.ttl`), named after the dataspace origin (deployment host dropped, role appended: `end-user`, `admin`, `northwind-traders.demo.end-user`, …), each under `fuseki//`; bound to apps in `config/system.trig` - **egress**: Squid forward proxy for the store's and platform's outbound requests (SPARQL `SERVICE`, `LOAD`): public destinations only, so a query cannot reach another dataset, Varnish or the platform +- **web-algebra**: REST-VKG's `webalgebra-server-ldh` image — executes Web-Algebra plans and writes them from natural language for the assistant drawer (`xsl/client/chat.xsl`). nginx forwards `/webalgebra` (a reserved path, like `/static/` and `/uploads/`) to it with the caller's certificate; it presents the secretary's certificate to this instance with `On-Behalf-Of` naming the caller, which `WebIDFilter` honours because every agent's WebID document carries ` acl:delegates `. It addresses the instance by its public origin, rewritten to `nginx:9443` (`WEBALGEBRA_PROXY_HOST`), the same trick the platform's own `ClientUriRewriteFilter` plays - **varnish-frontend/varnish-admin/varnish-end-user**: Caching layers (admin and end-user caches both front the single `fuseki`) ### Data Flow diff --git a/README.md b/README.md index c6a90abbd..e73710a1f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ The [`ldh` command line interface](#command-line-interface) is attached to every - `secrets/client_truststore_password.txt` - `secrets/owner_cert_password.txt` - `secrets/secretary_cert_password.txt` + - `secrets/openai_api_key.txt` — an OpenAI API key for the assistant drawer (the file may be empty, which leaves the assistant unable to write plans) The one you will need to remember in order to authenticate with LinkedDataHub using WebID client certificate is `owner_cert_password`. 5. Launch the application services by running this from command line: ```shell @@ -217,6 +218,8 @@ _:warning: Do not use blank nodes to identify applications or services. We recom
Password of the secretary's WebID certificate
client_truststore_password
Password of the client truststore
+
openai_api_key
+
OpenAI API key the web-algebra service writes the assistant's plans with. The service executes plans without it; the drawer then reports that a plan could not be written
google_client_id
Google's OAuth client ID
Login with Google authentication is enabled when this value is provided
diff --git a/docker-compose.yml b/docker-compose.yml index ff9d76b2e..87d893801 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,8 @@ secrets: file: ./secrets/secretary_cert_password.txt client_truststore_password: file: ./secrets/client_truststore_password.txt + openai_api_key: + file: ./secrets/openai_api_key.txt # google_client_id: # file: ./secrets/google/client_id.txt # google_client_secret: @@ -28,6 +30,8 @@ services: depends_on: linkeddatahub: condition: service_healthy + web-algebra: + condition: service_started # its upstream has to resolve when nginx starts ports: - ${HTTP_PORT}:8080 # allow Tomcat to do HTTP to HTTPS redirect - ${HTTPS_PORT}:8443 # HTTPS @@ -149,6 +153,36 @@ services: target: /etc/squid/squid.conf expose: - 3128 + web-algebra: # runs Web-Algebra plans against this instance, and writes them from natural language for the assistant drawer + image: ghcr.io/atomgraph/webalgebra-server-ldh:${WEBALGEBRA_VERSION:-latest} + mem_limit: 1024m + restart: on-failure + depends_on: + - egress + expose: + - 8080 # internal only: nginx is the sole client, and the server trusts the Client-Cert header only because nginx sets it + environment: + # the origin that receives an identity - this instance, with every dataspace subdomain under it. Its public name is + # not a route to it from inside the network, so requests for it go to nginx's client-cert listener under that name + - WEBALGEBRA_TRUSTED_ORIGIN=${PROTOCOL}://${HOST}:${HTTPS_PORT} + - WEBALGEBRA_PROXY_HOST=nginx + - WEBALGEBRA_PROXY_PORT=9443 + # the secretary's WebID: the agent every user's WebID document already delegates (root-owner.trig.template, SignUp), + # so a plan submitted by a user runs under that user's own access control, not the secretary's + - WEBALGEBRA_KEYSTORE=/var/webalgebra/ssl/secretary/keystore.p12 + - WEBALGEBRA_KEYSTORE_PASSWORD_FILE=/run/secrets/secretary_cert_password + - WEBALGEBRA_TRUSTSTORE=/var/webalgebra/ssl/server/server.crt # the self-signed server certificate, as PEM + - OPENAI_API_KEY_FILE=/run/secrets/openai_api_key + - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o} + # public destinations (SPARQL endpoints a plan names, the model's API) go through egress like the platform's own; nginx + # is reached directly, since egress refuses internal addresses + - JAVA_TOOL_OPTIONS=-Dhttp.proxyHost=${EGRESS_PROXY_HOST:-egress} -Dhttp.proxyPort=3128 -Dhttps.proxyHost=${EGRESS_PROXY_HOST:-egress} -Dhttps.proxyPort=3128 -Dhttp.nonProxyHosts=nginx + secrets: + - secretary_cert_password + - openai_api_key + volumes: + - ./ssl/secretary:/var/webalgebra/ssl/secretary:ro + - ./ssl/server:/var/webalgebra/ssl/server:ro varnish-frontend: image: varnish:7.7.3 user: root # otherwise varnish user does not have permissions to the mounted folder which is owner by root @@ -200,6 +234,10 @@ configs: } http { + upstream web-algebra { + server web-algebra:8080; + } + upstream linkeddatahub { server ${NGINX_UPSTREAM_SERVER:-varnish-frontend:6060}; } @@ -217,6 +255,7 @@ configs: limit_req_zone $$limit_key zone=linked_data:10m rate=15r/s; limit_req_zone $$limit_key zone=static_files:10m rate=20r/s; + limit_req_zone $$limit_key zone=web_algebra:10m rate=5r/s; # a plan is a model call; polling is what bursts limit_req_status 429; client_max_body_size ${MAX_CONTENT_LENGTH:-2097152}; @@ -231,6 +270,30 @@ configs: ssl_prefer_server_ciphers on; ssl_verify_client ${NGINX_SSL_VERIFY_CLIENT:-optional_no_ca}; + # the assistant's plan service. A reserved path, like /static/ and /uploads/: prefix-matched ahead of the + # generic location and never rewritten, so /webalgebra, /webalgebra/plans and /webalgebra/{id}/result reach the + # sidecar as they are. Reachable only with a WebID certificate on the connection: the sidecar acts for the + # WebID in the certificate nginx forwards, and a caller without one has nobody to act for. On-Behalf-Of is + # cleared for the same reason Client-Cert is - identity comes from the connection, never from a header + location ^~ /webalgebra { + if ($$ssl_client_verify = NONE) { + return 401; + } + + proxy_pass http://web-algebra; + limit_req zone=web_algebra burst=10 nodelay; + proxy_read_timeout 120s; # writing a plan is a 10-30 s model call + + proxy_set_header Host $$host; + proxy_set_header X-Forwarded-Host $$host; + proxy_set_header X-Forwarded-Proto $$scheme; + proxy_set_header X-Forwarded-Port ${HTTPS_PORT}; + + proxy_set_header Client-Cert ''; + proxy_set_header Client-Cert $$ssl_client_escaped_cert; + proxy_set_header On-Behalf-Of ''; + } + location / { add_header Access-Control-Allow-Origin "*" always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" always; diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css index 5070c76d3..8b8f78ea7 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css @@ -973,3 +973,79 @@ div.ldh-failure > .ac-alert { margin: 0; } .rdfa-editor-content th { background: var(--bg-recess); } .rdfa-editor-content caption { color: var(--fg-muted); } .rdfa-editor-content .rdfa-editor-island { border-color: var(--border-default); background: var(--bg-recess); } + +/* ========================================================================= + Assistant drawer (client/chat.xsl; the frame is layout.xsl's). + The core Drawer primitive (surfaces.css: .ac-drawer + head/title/body/foot) + parked below the sticky header instead of spanning the viewport, and lifted + onto LDH's chrome stacking scale: above the tab bar (88), below the header + (90), so the sticky chrome keeps its layering and modal backdrops (100+) + still cover it. The design's Drawer is not in the tree while closed; here + the server renders it once and the client toggles .is-open, so closed is a + state rather than an absence. + ========================================================================= */ +:root { --chat-w: min(400px, 92vw); } +.chat-drawer { top: var(--ldh-header-height); width: var(--chat-w); z-index: 89; } +.chat-drawer:not(.is-open) { display: none; } +.chat-sensor { top: var(--ldh-header-height); height: calc(100vh - var(--ldh-header-height)); z-index: 87; } +body:has(.chat-drawer.is-open) .chat-sensor { display: none; } +/* the edge handle is the design's, made a button: the drawer opens on a press, not on a pointer straying to the edge */ +.ldh-edge-handle.chat-open { padding: 0; font: inherit; cursor: pointer; } + +/* An open drawer insets the app frame rather than covering it: the drawer is a + fixed overlay, and LDH's chrome runs full width, so the action bar's mode + switcher and export menu would sit under it for the whole session. The + inset lands on the frame, so header, sticky bars, content and footer all + reflow together and nothing needs a per-bar width rule. */ +body:has(.chat-drawer.is-open) #visible-body { margin-right: var(--chat-w); } + +/* turns: the reader's message hugs the sender's edge, the assistant's cards sit flush left */ +.chat-turn { align-self: flex-end; max-width: 85%; margin: 0; padding: var(--sp-2) var(--sp-3); background: var(--bg-accent-quiet); color: var(--fg-1); border-radius: var(--r-md) var(--r-md) var(--r-xs) var(--r-md); font-size: var(--fs-sm); line-height: var(--lh-base); } +.ac-drawer-body.chat-log { display: flex; flex-direction: column; gap: var(--sp-3); } + +/* the plan card rides the design's nested-block well; chat scopes its rhythm */ +.chat-plan { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); font-size: var(--fs-sm); } +.chat-plan > p { margin: 0; line-height: var(--lh-base); color: var(--fg-2); } +.chat-plan-meta { font-family: var(--font-mono); font-size: 10px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--fg-muted); } +.chat-plan .ac-codefield { margin: var(--sp-2) 0 var(--sp-1); max-height: 210px; overflow: auto; } +.chat-plan .ac-codefield pre { margin: 0; padding: var(--sp-2) var(--sp-3); font-family: var(--font-mono); font-size: 11px; line-height: var(--lh-snug); color: var(--fg-2); white-space: pre; } +.chat-plan-actions { display: flex; gap: var(--sp-2); } + +/* The steps: one row per operation, there from the moment the plan is shown and changing state as the + executor reports - planned, running, done, failed. Each row is a disclosure whose summary is the row + and whose body is the operation's own XML (the first row's is the whole plan) and, when it failed, + the message; no marker, the row itself is the control. The tint is the status colour set the + design's alerts use (success-50 / danger-50), so a row that went green or red reads like the alert + it replaces */ +.chat-steps, .chat-steps ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +/* the rows nest as the operations do: a step inside another's argument sits one level in, under a hairline */ +.chat-steps ul { margin: 2px 0 0 calc(12px + var(--sp-1)); padding-left: var(--sp-2); border-left: 1px solid var(--border-default); } +details.chat-step { display: block; } +details.chat-step > summary { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: var(--sp-2); padding: var(--sp-1) var(--sp-2); border-radius: var(--r-sm); font-size: var(--fs-sm); color: var(--fg-1); cursor: pointer; list-style: none; } +details.chat-step > summary::-webkit-details-marker { display: none; } +details.chat-step > summary:hover { background: var(--bg-hover); } +.chat-step .st { display: inline-flex; color: var(--fg-hint); } +.chat-step .op { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-step.is-running > summary { background: var(--bg-accent-quiet); } +.chat-step.is-running .st { justify-self: center; } +.chat-step.is-done > summary { background: var(--success-50); } +.chat-step.is-done .st { color: var(--success-500); } +.chat-step.is-failed > summary { background: var(--danger-50); } +.chat-step.is-failed .st { color: var(--danger-500); } +.chat-step.kd-write .st { color: var(--warning-500); } +.chat-step.kd-destructive .st { color: var(--danger-500); } +.chat-step-message { margin: var(--sp-2) var(--sp-2) 0; font-size: var(--fs-sm); line-height: var(--lh-base); color: var(--fg-2); } + +/* changed-document links: one row per affected graph, mono URI leaf */ +.chat-docs .op { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 0; } + +/* a plan that only read shows its result set in the drawer's width */ +.chat-plan .chat-result { display: block; max-width: 100%; overflow-x: auto; } + +/* the composer rides the drawer's footer slot */ +.ac-drawer-foot.chat-composer { display: flex; flex-direction: column; gap: var(--sp-2); margin: 0; } +.chat-composer-row { display: flex; align-items: flex-end; gap: var(--sp-2); } +.chat-composer .ac-field { flex: 1; } +.chat-options { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-4); } +.chat-composer .ac-choice { font-size: var(--fs-sm); color: var(--fg-muted); } +.chat-composer textarea { resize: none; } diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl index a9fd3f2a1..313fa860f 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl @@ -87,6 +87,7 @@ extension-element-prefixes="ixsl" + @@ -191,6 +192,8 @@ WHERE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ +

+
+ +
+
+
+ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ +

+
+
+
+ + + +
+ + + + + +
+ + + + + + +

+ +

+
+ +
    + +
+
+ + +
+
+ + + + + + + +
  • +
    + + + + + + + + +
    + +
      + +
    +
    +
  • +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +                    
    +                
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + + +
      + + + + +
    +
    +
  • +
    +
    + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + +

    + +

    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + · + + + + + + + + + + + + + + + +
    + + + + + +
    +
    + + + + + + + + + + + + + + +
    + + + + + + + + + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + +
    +
    +
    +
    +
    + + + + + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/navigation.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/navigation.xsl index 6365195a5..3a2926fa4 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/navigation.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/navigation.xsl @@ -358,14 +358,15 @@ ORDER BY DESC(?created) - + - + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/layout.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/layout.xsl index 6350d6f10..66c3030c9 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/layout.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/layout.xsl @@ -761,8 +761,83 @@ WHERE
    + + + +
    + + + + + +
    + +
    +