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/.github/workflows/release.yml b/.github/workflows/release.yml index 385670d70..7480507ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,6 +161,13 @@ jobs: # strip the literal prefix echo "FULL_VERSION=${RAW_REF#linkeddatahub-}" >> $GITHUB_ENV + # The CLI resolves linkeddatahub-rdf at its own version, which the tagged checkout holds. Maven + # Central does not: release.sh deploys the library after pushing the tag, and Central publishes + # it minutes later, while this job runs seconds after the push. + - name: Build the linkeddatahub-rdf library + run: mvn -B install + working-directory: rdf + # release.sh keeps cli/pom.xml at the platform version, so the tagged commit already carries it # and the jar manifest ldh --version reads gets it from there - name: Build the ldh CLI diff --git a/AGENTS.md b/AGENTS.md index d0d322beb..0ae9e0ee1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,12 @@ The dataspace exposes a **read-only SPARQL 1.1 Query** endpoint (advertised via Write portable, standard SPARQL: use explicit `GRAPH` patterns, no engine-specific extensions. +**The default graph is empty.** Every document is a named graph whose name is the document's URL, so a triple pattern outside `GRAPH` matches nothing. Put the patterns inside `GRAPH ?g { ... }` to query across every document, or inside `GRAPH { ... }` for one document; a subquery needs its own `GRAPH` as well. A query that returns no rows without a `GRAPH` clause is not evidence that the data is absent. + +**One document per resource, so a join crosses graphs.** A resource is described in the document about it (`foaf:primaryTopic`), and the resources it links to - an order's customer, a product's category, an employee's manager - are each described in their own document, in another graph. A pattern that follows such a link therefore needs a `GRAPH` of its own for each resource: `GRAPH ?o { ?order schema:customer ?customer } GRAPH ?c { ?customer schema:legalName ?name }`, never both triples in one `GRAPH ?g`, which asks for them in the same document and matches nothing. Only a resource's own companions - its address, its contact point, an order's line items - share its document. When in doubt, give every subject variable its own graph variable: it costs nothing when two resources do share a document, and it is the only pattern that works when they do not. + +**A property is used on instances of its domain.** The ontology (`Link rel=lds:ontology`) declares each property's `rdfs:domain` and `rdfs:range`, and the data follows it: a property declared on `schema:Person` is not on the `schema:Corporation` that employs the person. To reach it, follow the ontology's path - the corporation's `schema:employee`, then the person's `schema:address`. + Outbound `SERVICE` and `LOAD` — from the triplestore and from the platform alike — are routed through the `egress` forward proxy, which refuses loopback, private and link-local destinations. A federated query reaches public endpoints and cannot reach the deployment's own services. With no proxy configured and `ALLOW_INTERNAL_URLS` unset, in-JVM `SERVICE` (in a `PATCH` update or an import mapping) is disabled outright. ## Content & document model diff --git a/CHANGELOG.md b/CHANGELOG.md index dd28de1de..783a08637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [Unreleased] +### Added +- An assistant 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, the documents the plan wrote and its result are reported as they happen, and the result is answered in words and drawn as a table, a list, a grid or a chart +- A conversation is an `ldh:Chat` block, placed in the document by an object block: the create bar's Assistant button starts one, each ends in its own composer, and every turn is stored as an `ldh:ChatTurn` with its plan and what the execution reported, so a chat is there when the reader comes back +- 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 +- The agent guide (`AGENTS.md`) is served at `/AGENTS.md` on every dataspace origin, public and outside the dispatcher, so a client writing a query against the endpoint - the assistant's `SPARQLString` reads it as service documentation - learns that every document is a named graph and the default graph is empty + ## [6.1.0] - 2026-10-05 ### Migration - **BREAKING**: `ldh` drops `-b`/`--base`; dataspace-level commands take the base URI as their positional, defaulting to `LDH_BASE` 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/Dockerfile b/Dockerfile index a95246e1f..35072ea33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,9 @@ RUN mvn -B -Pstandalone dependency:go-offline COPY src /usr/src/platform/src +# the agent guide the WAR serves at /AGENTS.md (pom.xml, maven-war-plugin webResources) +COPY AGENTS.md /usr/src/platform/AGENTS.md + RUN mvn -Pstandalone clean install # ============================== diff --git a/README.md b/README.md index c6a90abbd..85b374178 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` — optional: an OpenAI API key for the assistant drawer, declared in `docker-compose.override.yml` like the OAuth secrets; without it the assistant runs plans but cannot write them 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. Optional, declared in the override together with OPENAI_API_KEY_FILE; the service executes plans without it, and 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/cli/pom.xml b/cli/pom.xml index d78a8ae3d..ec5faef9e 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -4,7 +4,7 @@ com.atomgraph linkeddatahub-cli - 6.1.0 + 6.1.1-SNAPSHOT jar LinkedDataHub CLI diff --git a/docker-compose.yml b/docker-compose.yml index ff9d76b2e..6fadb1c27 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,38 @@ 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 + # the model key is optional, like the OAuth secrets: a deployment that writes plans declares the openai_api_key + # secret and this variable in its override; without them the service runs plans and declines to write any + # - 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 +236,10 @@ configs: } http { + upstream web-algebra { + server web-algebra:8080; + } + upstream linkeddatahub { server ${NGINX_UPSTREAM_SERVER:-varnish-frontend:6060}; } @@ -217,6 +257,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 +272,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/pom.xml b/pom.xml index 7719ca451..f204d29eb 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ com.atomgraph linkeddatahub - 6.1.0 + 6.1.1-SNAPSHOT ${packaging.type} AtomGraph LinkedDataHub @@ -46,7 +46,7 @@ https://github.com/AtomGraph/LinkedDataHub scm:git:git://github.com/AtomGraph/LinkedDataHub.git scm:git:git@github.com:AtomGraph/LinkedDataHub.git - linkeddatahub-6.1.0 + linkeddatahub-5.5.4 @@ -169,13 +169,13 @@ ${project.groupId} client - 6.0.3 + 6.0.4 classes ${project.groupId} client - 6.0.3 + 6.0.4 war @@ -399,6 +399,17 @@ ${project.build.directory}/${build.warName} true true + + + + ${basedir} + + AGENTS.md + + + ${project.groupId} diff --git a/rdf/pom.xml b/rdf/pom.xml index 1a66ffd6f..2244a400f 100644 --- a/rdf/pom.xml +++ b/rdf/pom.xml @@ -4,7 +4,7 @@ com.atomgraph linkeddatahub-rdf - 6.1.0 + 6.1.1-SNAPSHOT jar LinkedDataHub RDF diff --git a/rdf/src/main/java/com/atomgraph/linkeddatahub/rdf/vocabulary/LDH.java b/rdf/src/main/java/com/atomgraph/linkeddatahub/rdf/vocabulary/LDH.java index c4f2e0637..00953ffa9 100644 --- a/rdf/src/main/java/com/atomgraph/linkeddatahub/rdf/vocabulary/LDH.java +++ b/rdf/src/main/java/com/atomgraph/linkeddatahub/rdf/vocabulary/LDH.java @@ -45,6 +45,10 @@ private LDH() { } public static final Resource CSVImport = ResourceFactory.createResource(NS + "CSVImport"); /** ldh:RDFImport class */ public static final Resource RDFImport = ResourceFactory.createResource(NS + "RDFImport"); + /** ldh:Chat class: a conversation with the assistant, its turns the rdf:_N members */ + public static final Resource Chat = ResourceFactory.createResource(NS + "Chat"); + /** ldh:ChatTurn class: one question and what came of it */ + public static final Resource ChatTurn = ResourceFactory.createResource(NS + "ChatTurn"); /** ldh:MissingPropertyValue constraint class */ public static final Resource MissingPropertyValue = ResourceFactory.createResource(NS + "MissingPropertyValue"); /** ldh:ChildrenView resource */ @@ -62,6 +66,16 @@ private LDH() { } public static final Property delimiter = ResourceFactory.createProperty(NS + "delimiter"); /** ldh:chartType property */ public static final Property chartType = ResourceFactory.createProperty(NS + "chartType"); + /** ldh:question property: what a chat turn asked */ + public static final Property question = ResourceFactory.createProperty(NS + "question"); + /** ldh:answer property: the assistant's answer in words */ + public static final Property answer = ResourceFactory.createProperty(NS + "answer"); + /** ldh:plan property: the turn's Web-Algebra plan, an rdf:XMLLiteral */ + public static final Property plan = ResourceFactory.createProperty(NS + "plan"); + /** ldh:execution property: what running the plan reported, an rdf:XMLLiteral */ + public static final Property execution = ResourceFactory.createProperty(NS + "execution"); + /** ldh:outcome property: how the turn went, in one line */ + public static final Property outcome = ResourceFactory.createProperty(NS + "outcome"); /** ldh:categoryVarName property */ public static final Property categoryVarName = ResourceFactory.createProperty(NS + "categoryVarName"); /** ldh:seriesVarName property */ diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java index 222eb7a99..bb2d7591c 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java @@ -47,6 +47,7 @@ import com.atomgraph.core.io.ModelProvider; import com.atomgraph.core.io.QueryProvider; import com.atomgraph.core.io.ResultSetProvider; +import com.atomgraph.core.io.SPARQLResultProvider; import com.atomgraph.core.io.UpdateRequestProvider; import com.atomgraph.core.mapper.BadGatewayExceptionMapper; import com.atomgraph.core.provider.QueryParamProvider; @@ -1072,6 +1073,7 @@ public void init() register(new ValidatingModelProvider(getMessageDigest())); register(new ResultSetProvider()); + register(new SPARQLResultProvider()); // the boolean of an ASK, which ResultSetProvider cannot write register(new QueryProvider()); register(new QueryParamProvider()); register(new UpdateRequestProvider()); @@ -1723,6 +1725,7 @@ public void releaseConnection(final HttpClientConnection managedConn, final Obje config.register(new ModelProvider()); config.register(new DatasetProvider()); config.register(new ResultSetProvider()); + config.register(new SPARQLResultProvider()); config.register(new QueryProvider()); config.register(new UpdateRequestProvider()); config.property(ClientProperties.FOLLOW_REDIRECTS, true); @@ -1831,6 +1834,7 @@ public void releaseConnection(final HttpClientConnection managedConn, final Obje config.register(new ModelProvider()); config.register(new DatasetProvider()); config.register(new ResultSetProvider()); + config.register(new SPARQLResultProvider()); config.register(new QueryProvider()); config.register(new UpdateRequestProvider()); // TO-DO: UpdateRequestProvider config.property(ClientProperties.FOLLOW_REDIRECTS, true); diff --git a/src/main/resources/com/atomgraph/linkeddatahub/ldh.ttl b/src/main/resources/com/atomgraph/linkeddatahub/ldh.ttl index 68766a73d..b3a3132b3 100644 --- a/src/main/resources/com/atomgraph/linkeddatahub/ldh.ttl +++ b/src/main/resources/com/atomgraph/linkeddatahub/ldh.ttl @@ -111,6 +111,41 @@ rdfs:comment "Whether the view block is shown when its query returns no results. Defaults to true" ; rdfs:isDefinedBy : . +:question a owl:DatatypeProperty ; + rdfs:domain :ChatTurn ; + rdfs:range xsd:string ; + rdfs:label "Question" ; + rdfs:comment "What the reader asked the assistant in this turn" ; + rdfs:isDefinedBy : . + +:answer a owl:DatatypeProperty ; + rdfs:domain :ChatTurn ; + rdfs:range xsd:string ; + rdfs:label "Answer" ; + rdfs:comment "The assistant's reading, in words, of what the turn's plan did" ; + rdfs:isDefinedBy : . + +:plan a owl:DatatypeProperty ; + rdfs:domain :ChatTurn ; + rdfs:range rdf:XMLLiteral ; + rdfs:label "Plan" ; + rdfs:comment "The Web-Algebra plan the question became: the wa:plan envelope with its summary and operation" ; + rdfs:isDefinedBy : . + +:execution a owl:DatatypeProperty ; + rdfs:domain :ChatTurn ; + rdfs:range rdf:XMLLiteral ; + rdfs:label "Execution" ; + rdfs:comment "What running the plan reported: the wa:execution with its steps, the documents written and a capped snapshot of the result" ; + rdfs:isDefinedBy : . + +:outcome a owl:DatatypeProperty ; + rdfs:domain :ChatTurn ; + rdfs:range xsd:string ; + rdfs:label "Outcome" ; + rdfs:comment "How the turn went, in one line: rows or resources returned, documents written, no results, or the failure's message" ; + rdfs:isDefinedBy : . + # CLASSES # constructor @@ -343,6 +378,18 @@ rdfs:label "View constructor" ; rdfs:isDefinedBy : . +# chat + +:Chat a rdfs:Class, owl:Class ; + rdfs:label "Assistant" ; + rdfs:comment "A conversation with the assistant, placed in a document by an object block. Its turns are its rdf:_N members, in the order they were asked" ; + rdfs:isDefinedBy : . + +:ChatTurn a rdfs:Class, owl:Class ; + rdfs:label "Assistant turn" ; + rdfs:comment "One question to the assistant and what came of it" ; + rdfs:isDefinedBy : . + # exception :ResourceExistsException a rdfs:Class, owl:Class ; diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 252bc4e74..e12941edd 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -361,12 +361,19 @@ support@atomgraph.com]]> com.atomgraph.linkeddatahub.listener.ClientStylesheetListener + + md + text/markdown + default /static/* /robots.txt /favicon.ico /sitemap.xml + + /AGENTS.md com.atomgraph.linkeddatahub.Application diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/app.css b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/app.css index bd2a40125..ae6a7e9cb 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/app.css +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/app.css @@ -2154,6 +2154,8 @@ html[data-tweak-block-edges="flow"] .ldh-block:has(.ldh-edit-form) { .ldh-view-toolbar.is-collapsed, .ldh-pivot-bar.is-collapsed, .chart-controls.is-collapsed { display: none; } +/* the chart's Save stores what its controls picked, so it goes with them */ +.chart-controls.is-collapsed ~ .ldh-block-foot { display: none; } .ldh-grid-block .card { position: relative; display: flex; flex-direction: column; align-items: stretch; @@ -4599,3 +4601,89 @@ html[data-tweak-block-edges="flow"] .ldh-block:has(.ldh-edit-form) { box-shadow: inset 2px 0 0 var(--fg-selected); } .sb-other > li.is-active > * { color: var(--fg-selected); font-weight: var(--fw-medium); } + +/* ========================================================================= + The create bar and the assistant (Assistant.jsx). + + The create bar is the strip at the foot of the content body that holds the + document's Create button and, for a reader who can be acted for, the + Assistant button. The host decides where the bar parks (LinkedDataHub pins + it sticky at the column's foot); this is what it looks like. + + A conversation is a BLOCK - a chat, placed in the document like a view or a + chart - headed by THE block header, so a page holds as many as it has chat + blocks, and the Assistant button adds another before the bar. Its body is the + log, then its own composer as the block's footer: two checkboxes over the + question and the send button. A turn is the reader's question; a plan card is + a nested block holding the plan's summary, its operations as step rows + (planned / running / done / failed, each a disclosure folding out its XML + and, when it failed, the message), and what came of it: the documents + written, the rows returned. + ========================================================================= */ +.ldh-create-dock { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--sp-2); padding: var(--sp-2) var(--sp-6); background: var(--bg-card); border-top: 1px solid var(--border-default); } +.ldh-chat > form.ldh-chat-composer { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border-default); } +.ldh-chat-composer-row { display: flex; align-items: flex-end; gap: var(--sp-2); } +.ldh-chat-composer .ac-field { flex: 1; } +.ldh-chat-options { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-4); } +.ldh-chat-composer .ac-choice { font-size: var(--fs-sm); color: var(--fg-muted); } +.ldh-chat-composer textarea { resize: none; } + +/* an empty log takes no room: a new chat block is its header and its composer */ +.ldh-chat-log:empty { display: none; } +.ldh-chat-log { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3); } +/* turns: the reader's message hugs the sender's edge, the assistant's cards sit flush left */ +.ldh-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); } +/* the plan card rides the nested-block well; chat scopes its rhythm */ +.ldh-chat-plan { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); font-size: var(--fs-sm); } +.ldh-chat-plan > p { margin: 0; line-height: var(--lh-base); color: var(--fg-2); } +.ldh-chat-plan-meta { font-family: var(--font-mono); font-size: 10px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--fg-muted); } +.ldh-chat-plan .ac-codefield { margin: var(--sp-2) 0 var(--sp-1); max-height: 210px; overflow: auto; } +/* the field is the kit's flex row; the pre stands where its .ac-cf-area would and fills the row like it, else it is only as wide as its longest line */ +.ldh-chat-plan .ac-codefield pre { flex: 1; min-width: 0; 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; } +.ldh-chat-plan-actions { display: flex; gap: var(--sp-2); } +/* the answer: the card's first line once there is one, read off the rows below it */ +.ldh-chat-answer { margin: 0; font-size: var(--fs-md); line-height: var(--lh-base); color: var(--fg-1); white-space: pre-line; } +/* the trace: the steps under a disclosure whose summary is their count and the plan's time - open while the plan is + being read or run, folded once the answer has been written. No marker; the meta line is the control */ +details.ldh-chat-trace > summary { font-family: var(--font-mono); font-size: 10px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--fg-muted); cursor: pointer; list-style: none; } +/* the summary is a control and looks like one: a chevron that turns when open, and the outcome's glyph */ +details.ldh-chat-trace > summary { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 2px var(--sp-2) 2px 2px; border-radius: var(--r-sm); } +details.ldh-chat-trace > summary:hover { background: var(--bg-hover); } +details.ldh-chat-trace > summary > .chev { transition: transform var(--dur-fast, 120ms) ease; } +details.ldh-chat-trace[open] > summary > .chev { transform: rotate(90deg); } +details.ldh-chat-trace > summary > .st.is-done { color: var(--success-500); } +details.ldh-chat-trace > summary > .st.is-failed { color: var(--danger-500); } +details.ldh-chat-trace > summary::-webkit-details-marker { display: none; } +details.ldh-chat-trace > summary:hover { color: var(--fg-2); } +details.ldh-chat-trace > ul { margin-top: 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 + alerts use (success-50 / danger-50), so a row that went green or red reads like the alert it replaces */ +.ldh-chat-steps, .ldh-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 */ +.ldh-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.ldh-chat-step { display: block; } +details.ldh-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.ldh-chat-step > summary::-webkit-details-marker { display: none; } +details.ldh-chat-step > summary:hover { background: var(--bg-hover); } +.ldh-chat-step .st { display: inline-flex; color: var(--fg-hint); } +.ldh-chat-step .op { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ldh-chat-step.is-running > summary { background: var(--bg-accent-quiet); } +.ldh-chat-step.is-running .st { justify-self: center; } +.ldh-chat-step.is-done > summary { background: var(--success-50); } +.ldh-chat-step.is-done .st { color: var(--success-500); } +.ldh-chat-step.is-failed > summary { background: var(--danger-50); } +.ldh-chat-step.is-failed .st { color: var(--danger-500); } +.ldh-chat-step.kd-write .st { color: var(--warning-500); } +.ldh-chat-step.kd-destructive .st { color: var(--danger-500); } +.ldh-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 */ +.ldh-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 block's width */ +.ldh-chat-plan .ldh-chat-result { display: block; max-width: 100%; overflow-x: auto; } +/* a result sits in a well of its own under the card - depth 2 - headed like a block; a chart's canvas gets the height a chart block gets */ +.ldh-chat-result-block { margin-top: var(--sp-2); } +.ldh-chat-result-block > .ldh-nblock-body { padding: var(--sp-2) var(--sp-3) var(--sp-3); } +.ldh-chat-result-block .ldh-chat-chart { min-height: 320px; } 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..d5da76f56 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/ldh.css @@ -135,7 +135,7 @@ object { display: block; margin: auto; max-height: 400px; overflow: hidden; widt .graph-3d-show-panel label.sub-option { margin-left: 16px; } .graph-3d-zoom { position: absolute; top: 10px; left: 10px; z-index: 10; } .graph-3d-fullscreen { position: absolute; bottom: 10px; right: 10px; z-index: 10; } -.graph-3d-canvas.graph-3d-maximized { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; } +.graph-3d-canvas.graph-3d-maximized { position: fixed; inset: 0; width: auto; height: auto; z-index: 1000; } /* OpenLayers specific */ /* the marker info window borrows the modal's head/body anatomy but its host is ol.Overlay's own wrapper, not a dialog - so the panel treatment lands here: the overlay IS the card, which is why @@ -268,7 +268,7 @@ circle { cursor: move; } /* ---------- sticky chrome: header on top, action bar (legacy --action-bar-top) right under it. Bar heights are tokens that size the bars, not mirrors of their content, so stacked sticky offsets (.editor-bar) can compose them without drifting ---------- */ -:root { --ldh-header-height: 56px; --ldh-tabbar-height: 40px; --ldh-actionbar-height: 61px; --action-bar-top: var(--ldh-header-height); --ldh-content-w: 1280px; } +:root { --ldh-header-height: 56px; --ldh-tabbar-height: 40px; --ldh-actionbar-height: 61px; --action-bar-top: var(--ldh-header-height); --ldh-content-w: 1280px; --ldh-pane-w: 100vw; /* the width the dataspace's panes span: the viewport, less whatever stands beside them */ } /* image caps for RDF-pointed depictions: the pixel size is whatever the dataset points at, so the SURFACE picks the height. These once lived in the pre-refresh vendored app.css; a re-vendor lost the definitions while the var() uses stayed, silently voiding every declaration that read them @@ -583,21 +583,21 @@ button.tree-link { border: 0; background: transparent; width: 100%; font: inheri modes' Create picker) sits in a full-bleed header-like row. Sticky, not fixed: it rides the viewport bottom while the content scrolls, but parks at its flow position at the end of the content - stacked on the footer, never over it. The negative side margins bleed - the row out of the centered content column to the viewport edges; the right padding brings + the row out of the centered content column to the panes' edges; the right padding brings the buttons back to the content column's right edge, flush with the blocks. The margin bleed - and the padding must share the 100vw base: each is half a classic scrollbar off the layout - width, in the same direction, so the errors cancel and the buttons land exactly on the column - edge - rebasing one without the other breaks the alignment ---------- */ + and the padding must share the --ldh-pane-w base: each is half a classic scrollbar off the + layout width, in the same direction, so the errors cancel and the buttons land exactly on the + column edge - rebasing one without the other breaks the alignment. The base is the panes' + width rather than the viewport's because the assistant drawer stands beside the panes and + narrows it (the chat section below) ---------- */ html { overflow-x: clip; } /* the 50vw bleed overshoots by half a classic scrollbar; clip never creates a scroll container */ +/* the bar's look is the app kit's (app.css, .ldh-create-dock); this is where the page parks it */ .ldh-create-dock { position: sticky; bottom: 0; - display: flex; align-items: center; justify-content: flex-end; gap: var(--sp-2); z-index: 24; /* above content, below popovers (40) and modal chrome */ - margin: auto calc(50% - 50vw) 0; /* margin-top: auto parks it at the bottom of the stretched column */ - padding: var(--sp-2) calc((100vw - min(var(--ldh-content-w), 100vw)) / 2 + var(--sp-6)) var(--sp-2) var(--sp-6); - background: var(--bg-card); - border-top: 1px solid var(--border-default); + margin: auto calc(50% - var(--ldh-pane-w) / 2) 0; /* margin-top: auto parks it at the bottom of the stretched column */ + padding-right: calc((var(--ldh-pane-w) - min(var(--ldh-content-w), var(--ldh-pane-w))) / 2 + var(--sp-6)); } /* the modal dim overlay is pointer-inert, so hide the dock while a modal is open */ body:has(.ac-backdrop.modal) .ldh-create-dock { display: none; } @@ -973,3 +973,5 @@ 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); } + + 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..9042de2bd 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,9 @@ WHERE + + + + + @@ -473,53 +481,16 @@ WHERE a second map in every other tab's map canvas (measured 2026-09-28 with three dataspace tabs) --> - - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + @@ -781,6 +752,7 @@ WHERE + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl index 3c40190cc..962509ff7 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl @@ -225,7 +225,8 @@ exclude-result-prefixes="#all" - + + @@ -406,13 +407,14 @@ exclude-result-prefixes="#all" - - - - - + + + + + - + @@ -423,101 +425,94 @@ exclude-result-prefixes="#all" - - - - - + + + Chart control values missing for content '' - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - Chart control values missing for content '' - - - - + + + - + - - - - - - - - - - - - - - - - - - - + + + + + + - - Chart control values missing for content '' - + + + + + + + + - - + + - - - - - - - + + + - + + + + + + + + - - - - - - - - - - + + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/object.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/object.xsl index 16a67382c..15f7558f1 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/object.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/object.xsl @@ -313,62 +313,15 @@ exclude-result-prefixes="#all" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/query.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/query.xsl index bc9d66609..296a4cae9 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/query.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/query.xsl @@ -737,18 +737,15 @@ exclude-result-prefixes="#all" - + - - - - - - - - - - + + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/view.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/view.xsl index cb169ce67..eb697e11b 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/view.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/view.xsl @@ -1038,124 +1038,37 @@ exclude-result-prefixes="#all" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + +
+ + - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/chat.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/chat.xsl new file mode 100644 index 000000000..b56f400dc --- /dev/null +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/chat.xsl @@ -0,0 +1,1844 @@ + + + + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ +
+ + + + + +
+
+ + + + + +
+
+ + +
+
+
+
+