diff --git a/README.md b/README.md index 063fd949b..151d3176c 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ USAGE * [`apimatic help [COMMAND]`](#apimatic-help-command) * [`apimatic plugin generate`](#apimatic-plugin-generate) * [`apimatic plugin publish`](#apimatic-plugin-publish) +* [`apimatic portal copilot sync`](#apimatic-portal-copilot-sync) * [`apimatic portal generate`](#apimatic-portal-generate) * [`apimatic portal serve`](#apimatic-portal-serve) * [`apimatic publishing profile list`](#apimatic-publishing-profile-list) @@ -290,6 +291,47 @@ EXAMPLES _See code: [src/commands/plugin/publish.ts](https://github.com/apimatic/apimatic-cli/blob/v1.3.1/src/commands/plugin/publish.ts)_ +## `apimatic portal copilot sync` + +Update the portal's Ask AI chat with the portal's current pages. + +``` +USAGE + $ apimatic portal copilot sync [--wait] [-d ] [-i ] [-k ] + +FLAGS + -d, --destination= [default: /portal] path to the generated portal to sync. + -i, --input= [default: ./] path to the parent directory containing the 'src' directory, which includes + API specifications and configuration files. + -k, --auth-key= override current authentication state with an authentication key. + --[no-]wait wait until the pages are indexed; with --no-wait, return as soon as they are uploaded. + +DESCRIPTION + Update the portal's Ask AI chat with the portal's current pages. + + Uploads the pages of a generated portal to the Ask AI service, so the portal's Ask AI chat answers from the docs as + they are now. + + The service picks up the pages by itself from a portal deployed at its 'site.url'. Run this command after deploying a + portal generated with 'apimatic portal generate' when it cannot: for a portal without 'site.url', or one the service + cannot reach, such as one on a private network. + + The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json': APIMatic's Ask AI + service, or one on this machine. The pages are swapped in at once, after all of them are indexed; until then Ask AI + answers from the previous ones. + + The upload is made with your saved APIMatic login, or with the auth key passed with --auth-key. + +EXAMPLES + apimatic portal copilot sync + + apimatic portal copilot sync --input=./ --destination=./portal + + apimatic portal copilot sync --no-wait +``` + +_See code: [src/commands/portal/copilot/sync.ts](https://github.com/apimatic/apimatic-cli/blob/v1.3.1/src/commands/portal/copilot/sync.ts)_ + ## `apimatic portal generate` Generate a static API Documentation Portal. diff --git a/apimatic.schema.json b/apimatic.schema.json index e305cf119..a28461f5d 100644 --- a/apimatic.schema.json +++ b/apimatic.schema.json @@ -174,6 +174,30 @@ "type": "boolean", "default": true, "description": "Whether each page offers to open itself in an external AI assistant." + }, + "copilot": { + "type": "object", + "description": "The Ask AI chat the portal offers. Left out, the portal offers none.", + "additionalProperties": false, + "required": ["key", "apiUrl"], + "properties": { + "key": { + "type": "string", + "description": "A public Ask AI key, bound to this portal and to the origins allowed to use it.", + "minLength": 1 + }, + "apiUrl": { + "type": "string", + "description": "The address of APIMatic's Ask AI service, such as 'https://ask.apimatic.io', or of one on this machine while developing it, such as 'http://localhost:8787'. The address alone, without a path.", + "pattern": "^([hH][tT][tT][pP][sS]://([A-Za-z0-9_-]+\\.)*[aA][pP][iI][mM][aA][tT][iI][cC]\\.[iI][oO]|[hH][tT][tT][pP][sS]?://([lL][oO][cC][aA][lL][hH][oO][sS][tT]|127\\.0\\.0\\.1|\\[::1\\]))(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}|0))?/?$" + }, + "suggestions": { + "type": "array", + "description": "Up to three starter questions shown on a new chat, such as 'How do I authenticate?'. Left out, the chat shows its own.", + "maxItems": 3, + "items": { "type": "string", "minLength": 1, "maxLength": 120 } + } + } } } }, diff --git a/eslint.config.js b/eslint.config.js index c82907cca..22146f6f9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -40,7 +40,13 @@ export default [ before: 'readonly', beforeEach: 'readonly', after: 'readonly', - afterEach: 'readonly' + afterEach: 'readonly', + // The portal template's browser code is tested against Node's own fetch API. + fetch: 'readonly', + Headers: 'readonly', + Response: 'readonly', + RequestInfo: 'readonly', + RequestInit: 'readonly' } }, rules: { diff --git a/package.json b/package.json index 69aef9d9e..d6d04a399 100644 --- a/package.json +++ b/package.json @@ -54,11 +54,13 @@ "test": "tsx node_modules/mocha/bin/_mocha --forbid-only \"test/**/*.test.ts\" --timeout 99999" }, "dependencies": { + "@ai-sdk/react": "4.0.131", "@apimatic/sdk": "0.2.0-alpha.10", "@clack/core": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1", "@fontsource-variable/geist": "5.3.0", "@fontsource-variable/geist-mono": "5.3.0", + "@fumadocs/ai-chat": "0.1.0", "@fumadocs/api-docs": "0.2.8", "@oclif/core": "^4.2.8", "@oclif/plugin-autocomplete": "^3.2.24", @@ -70,6 +72,7 @@ "@tanstack/start-static-server-functions": "1.167.33", "@vitejs/plugin-react": "6.1.1", "adm-zip": "^0.5.17", + "ai": "7.0.128", "ansis": "^3.17.0", "async-mutex": "^0.5.0", "axios": "^1.8.1", @@ -177,6 +180,9 @@ "portal": { "description": "Build and serve an API Documentation portal for your APIs." }, + "portal:copilot": { + "description": "Keep your portal's Ask AI chat in sync with its pages." + }, "sdk": { "description": "Generate and publish SDKs for your APIs in multiple languages." }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a6c76e36..ec0836820 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: .: dependencies: + '@ai-sdk/react': + specifier: 4.0.131 + version: 4.0.131(react@19.2.8)(zod@4.6.5) '@apimatic/sdk': specifier: 0.2.0-alpha.10 version: 0.2.0-alpha.10 @@ -37,6 +40,9 @@ importers: '@fontsource-variable/geist-mono': specifier: 5.3.0 version: 5.3.0 + '@fumadocs/ai-chat': + specifier: 0.1.0 + version: 0.1.0(@ai-sdk/react@4.0.131(react@19.2.8)(zod@4.6.5))(@types/react@19.3.0)(ai@7.0.128(zod@4.6.5))(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(fumadocs-ui@16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/api-docs': specifier: 0.2.8 version: 0.2.8(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(fumadocs-ui@16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -70,6 +76,9 @@ importers: adm-zip: specifier: ^0.5.17 version: 0.5.17 + ai: + specifier: 7.0.128 + version: 7.0.128(zod@4.6.5) ansis: specifier: ^3.17.0 version: 3.17.0 @@ -320,6 +329,34 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@ai-sdk/gateway@4.0.104': + resolution: {integrity: sha512-4lTyt8a4BMfVz4QGo+VO5odakmYMX5kq+xiRgdQGGY7o/jP2WSasp6LvIvYvt/lHzz1MfE+xKTOJhz7mMwjynA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/mcp@2.0.67': + resolution: {integrity: sha512-cMEif3Lbvh+FozPpBM5UPy6QQhH8v8L/u1kcR2UXp+86EfIDXIaIeOaH+wYCk2CfloFccUn1K8LJuPXqZMZsfw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.54': + resolution: {integrity: sha512-amqDxFnw9+dpVrDACRDGmExUUNOH9C1D2bhg/DqWJnamEOieDTXBBXLHujSby/0MqHdAFksnpE8Pzfqx6ABMBw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.22': + resolution: {integrity: sha512-1Jtmn36VNMOBo9CMUQrPqsR744qtqRloEiXpWCYS8ffPlhS2CrSNmoho5lPjp3R7EnkzBsGI6p51TA7fQJQZdA==} + engines: {node: '>=22'} + + '@ai-sdk/react@4.0.131': + resolution: {integrity: sha512-DE2vKOAXkntnxClHEZoAVTys3eE1Ypm+SMCRhveQeTxYyJMszbRIEN5vW+mV5FjRH/UQWt1SeaPaXviCMYLWFA==} + engines: {node: '>=22'} + peerDependencies: + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@apimatic/authentication-adapters@0.5.14': resolution: {integrity: sha512-V7nhHShPrU8LfjKKHoVJNS50SveSL77CexVuS4aeQyXx99HwdQVJwl2MK0KAYM6/b2ufQbJ7Eee2fzQT0TVXSQ==} engines: {node: '>=14.15.0 || >=16.0.0'} @@ -1104,6 +1141,20 @@ packages: '@types/react': optional: true + '@fumadocs/ai-chat@0.1.0': + resolution: {integrity: sha512-HLTyqhxVxLS6gTBFn7e87HwGTXib2h5L2/myH588gl3HDUSrvYUSjZlRkb80GV3kFmnkBdK48CdHMVfSGTbcPA==} + peerDependencies: + '@ai-sdk/react': ^4.0.0 + '@types/react': '*' + ai: ^7.0.0 + fumadocs-core: ^16.10.0 + fumadocs-ui: ^16.10.0 + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@fumadocs/api-docs@0.2.8': resolution: {integrity: sha512-oJv1Y1TkyPGs6AFuogSgaH+wgebPRXPH9V2S0ZO/gGyt+pfFltP4XdmO4hoiVeuLiPMsSkm/FunuimeyscsQYA==} peerDependencies: @@ -2835,6 +2886,10 @@ packages: '@ungap/structured-clone@1.4.0': resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@6.1.1': resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2851,6 +2906,9 @@ packages: oxc-transform-react: optional: true + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@yuku-core/binding-android-arm64@0.17.0': resolution: {integrity: sha512-Uge3U7s9vdCcYb9IiIWBIQmGEttlvWkvWYb9Qu6iUNS2IhAu4/D/g4ucbEmjemkflWYGpWsKZlX+JxcacIlR+g==} cpu: [arm64] @@ -2950,6 +3008,12 @@ packages: resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} engines: {node: '>=18'} + ai@7.0.128: + resolution: {integrity: sha512-WMKN83DodYtH52ETPHq0jrSwj3wU0aHkhLHXMrwMRabC/aMpIqeWsXJgjkUwrOqkaKHWONdupk/2ttPRvAyFcg==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -3786,6 +3850,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -4715,6 +4783,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -5693,6 +5764,10 @@ packages: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-conf@2.1.0: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} @@ -5895,6 +5970,9 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + remend@1.4.0: + resolution: {integrity: sha512-mHiYhhwphs1IgIZmkToKLwJn0U99IjPXuutkA2acrNcG0vlEq2G4TGnd2eT0saIHwh2KjaCpvleHcsd5SAz96A==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -6269,6 +6347,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swr@2.5.1: + resolution: {integrity: sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -6299,6 +6382,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + throttleit@2.1.0: + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} + engines: {node: '>=18'} + through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} @@ -6850,6 +6937,46 @@ snapshots: '@actions/io@3.0.2': {} + '@ai-sdk/gateway@4.0.104(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.22 + '@ai-sdk/provider-utils': 5.0.54(zod@4.6.5) + '@vercel/oidc': 3.2.0 + zod: 4.6.5 + + '@ai-sdk/mcp@2.0.67(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.22 + '@ai-sdk/provider-utils': 5.0.54(zod@4.6.5) + cross-spawn: 7.0.6 + pkce-challenge: 5.0.1 + zod: 4.6.5 + + '@ai-sdk/provider-utils@5.0.54(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.22 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + undici: 7.29.1 + zod: 4.6.5 + + '@ai-sdk/provider@4.0.22': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/react@4.0.131(react@19.2.8)(zod@4.6.5)': + dependencies: + '@ai-sdk/mcp': 2.0.67(zod@4.6.5) + '@ai-sdk/provider': 4.0.22 + '@ai-sdk/provider-utils': 5.0.54(zod@4.6.5) + ai: 7.0.128(zod@4.6.5) + react: 19.2.8 + swr: 2.5.1(react@19.2.8) + throttleit: 2.1.0 + transitivePeerDependencies: + - zod + '@apimatic/authentication-adapters@0.5.14': dependencies: '@apimatic/core-interfaces': 0.2.14 @@ -7928,6 +8055,31 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 + '@fumadocs/ai-chat@0.1.0(@ai-sdk/react@4.0.131(react@19.2.8)(zod@4.6.5))(@types/react@19.3.0)(ai@7.0.128(zod@4.6.5))(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(fumadocs-ui@16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@ai-sdk/react': 4.0.131(react@19.2.8)(zod@4.6.5) + '@base-ui/react': 1.8.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@fuma-translate/react': 1.0.2(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + ai: 7.0.128(zod@4.6.5) + cn: 0.4.0 + fumadocs-core: 16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5) + fumadocs-ui: 16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + hast-util-to-jsx-runtime: 2.3.6 + lucide-react: 1.54.0(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + remark: 15.0.1 + remark-gfm: 4.0.1 + remark-rehype: 11.1.2 + remend: 1.4.0 + unist-util-visit: 5.1.0 + optionalDependencies: + '@types/react': 19.3.0 + transitivePeerDependencies: + - '@date-fns/tz' + - date-fns + - supports-color + '@fumadocs/api-docs@0.2.8(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(fumadocs-ui@16.17.1(@types/mdx@2.0.14)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(fumadocs-core@16.17.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.33(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.3.0)(lucide-react@1.54.0(@types/react@19.3.0)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@base-ui/react': 1.8.0(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -9842,11 +9994,15 @@ snapshots: '@ungap/structured-clone@1.4.0': {} + '@vercel/oidc@3.2.0': {} + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.2.2(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.1) + '@workflow/serde@4.1.0': {} + '@yuku-core/binding-android-arm64@0.17.0': optional: true @@ -9907,6 +10063,13 @@ snapshots: clean-stack: 5.3.0 indent-string: 5.0.0 + ai@7.0.128(zod@4.6.5): + dependencies: + '@ai-sdk/gateway': 4.0.104(zod@4.6.5) + '@ai-sdk/provider': 4.0.22 + '@ai-sdk/provider-utils': 5.0.54(zod@4.6.5) + zod: 4.6.5 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -10896,6 +11059,8 @@ snapshots: esutils@2.0.3: {} + eventsource-parser@3.1.1: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -11881,6 +12046,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} @@ -13073,6 +13240,8 @@ snapshots: pify@3.0.0: {} + pkce-challenge@5.0.1: {} + pkg-conf@2.1.0: dependencies: find-up: 2.1.0 @@ -13340,6 +13509,8 @@ snapshots: transitivePeerDependencies: - supports-color + remend@1.4.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -13795,6 +13966,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swr@2.5.1(react@19.2.8): + dependencies: + dequal: 2.0.3 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + tagged-tag@1.0.0: {} tailwindcss@4.3.3: {} @@ -13824,6 +14001,8 @@ snapshots: dependencies: any-promise: 1.3.0 + throttleit@2.1.0: {} + through2@2.0.5: dependencies: readable-stream: 2.3.8 diff --git a/portal-template/portal-config.ts b/portal-template/portal-config.ts index eaedaf55c..4adc4657a 100644 --- a/portal-template/portal-config.ts +++ b/portal-template/portal-config.ts @@ -25,7 +25,7 @@ export async function readBuildPaths(): Promise { } /** The part of what the browser is told that the build set-up reads too. */ -export type BuildIdentity = Pick; +export type BuildIdentity = Pick; export async function readPortalIdentity(): Promise { return (await readJson('./portal.identity.json')) as BuildIdentity; diff --git a/portal-template/prerender-pages.ts b/portal-template/prerender-pages.ts index 0560ef52f..1c2dbe41f 100644 --- a/portal-template/prerender-pages.ts +++ b/portal-template/prerender-pages.ts @@ -1,7 +1,7 @@ import { readdir } from 'node:fs/promises'; import path from 'node:path'; import { getSlugs, loader } from 'fumadocs-core/source'; -import type { BuildPaths } from './portal-config.ts'; +import type { BuildIdentity, BuildPaths } from './portal-config.ts'; import { withBasePath } from './src/lib/base-path'; import { oauthCallbackPath } from './src/lib/oauth-callback'; import { openApiSection } from './src/lib/openapi-section.server'; @@ -16,8 +16,13 @@ const isSkippedByGlob = (segment: string) => segment.startsWith('.') || segment * collapsed sidebar folders and never sees the `.md` URLs the page actions fetch, so * the list is computed here from the same sources the site is built from. */ -export async function prerenderPages(config: BuildPaths, siteUrl: string | null): Promise<{ path: string }[]> { +export async function prerenderPages( + config: BuildPaths, + { siteUrl, copilot }: BuildIdentity +): Promise<{ path: string }[]> { const urls = new Set(['/', '/api/search.json', '/llms.txt', '/llms-full.txt']); + // The pages the CLI bundles for Ask AI, so only for a portal that offers it. + if (copilot) urls.add('/api/copilot-pages.json'); // Both need absolute URLs, so they are only emitted for a portal that declares its address. if (siteUrl) { urls.add('/sitemap.xml'); diff --git a/portal-template/src/components/copilot.tsx b/portal-template/src/components/copilot.tsx new file mode 100644 index 000000000..bd11a955f --- /dev/null +++ b/portal-template/src/components/copilot.tsx @@ -0,0 +1,104 @@ +// The Ask AI copilot: Fumadocs' AI chat panel, answered by the Ask AI service (the portal is static). +// `@fumadocs/ai-chat` is 0.x and needs fumadocs-ui >= 16.16: keep it pinned and check the chat on every upgrade. +import { useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport, type UIMessage } from 'ai'; +import { AIChatPanel, AIChatProvider, AIChatSearch, AIChatSources, AIChatTrigger, useAIChat } from '@fumadocs/ai-chat'; +import { CopilotClient, questionMessage } from '@/lib/copilot-client'; +import type { PortalCopilot } from '@/lib/portal-types'; + +/** One part of a chat message: text, a tool call, a data part, and so on. */ +type Part = UIMessage['parts'][number]; +// Taken from the components themselves, so they cannot drift. +type SearchPart = Parameters[0]['part']; +type Sources = Parameters[0]['sources']; + +/** What the layout hands the chat: whether its panel is open, and where the layout keeps a place for it. */ +export type CopilotChatProps = Readonly<{ + copilot: PortalCopilot; + open: boolean; + onOpenChange: (open: boolean) => void; + /** The element the layout keeps in its panel slot; null until the layout has rendered it. */ + panel: HTMLElement | null; +}>; + +/** The module's one export, so the layout can load it on its own, and only for a portal that offers the chat. */ +export default function CopilotChat({ copilot, open, onOpenChange, panel }: CopilotChatProps) { + return ( + + + {panel !== null && createPortal(, panel)} + + ); +} + +/** Either side may open or close the panel; each passes on only its own change, so the two settle at once. */ +function FollowLayout({ open, onOpenChange }: Readonly<{ open: boolean; onOpenChange: (open: boolean) => void }>) { + const chat = useAIChat(); + // The layout's own control closed or opened it. + useEffect(() => chat.setOpen(open), [open, chat.setOpen]); + // The chat's button, Ctrl+/ or Escape did. + useEffect(() => onOpenChange(chat.open), [chat.open, onOpenChange]); + return null; +} + +/** The chat state and the "Ask AI" button; beside the layout, so a conversation survives navigation. */ +function CopilotProvider({ copilot, children }: Readonly<{ copilot: PortalCopilot; children: ReactNode }>) { + // Turned away by the service (disabled, unknown key, another website): the portal shows no Ask AI at all. + const [available, setAvailable] = useState(true); + const client = useMemo(() => new CopilotClient(copilot, () => setAvailable(false)), [copilot]); + const transport = useMemo(() => chatTransport(client), [client]); + const chat = useChat({ transport, onData: client.noteData }); + const suggestions = copilot.suggestions.length > 0 ? copilot.suggestions : undefined; + return ( + + + {children} + {available && } + + ); +} + +// The chat's own message would send the page's whole address, its query and fragment too. +function askFromThisPage(text: string) { + return questionMessage(text, { href: location.href, title: document.title }); +} + +/** Opens the session as the panel opens, and keeps the panel shut once the service turns the portal away. */ +function SessionOnOpen({ client, available }: Readonly<{ client: CopilotClient; available: boolean }>) { + const { open, setOpen } = useAIChat(); + useEffect(() => { + if (open) void client.prepare(); + }, [open, client]); + // Before paint, so Ctrl+/ on a portal turned away never shows the panel at all. + useLayoutEffect(() => { + if (open && !available) setOpen(false); + }, [open, available, setOpen]); + return null; +} + +/** + * Draws the non-text parts the service sends; the panel draws text itself. + * A new part type from the service needs a branch here, or it is not shown. + * Pages are linked by their path within the portal, as the service sends them: the router adds the base path. + */ +function renderPart(part: Part, live: boolean): ReactNode { + // "Searched · N results", expandable to the pages the answer was based on. + if (part.type === 'tool-search') return ; + // The numbered source chips, matching the [1], [2] citations in the answer. + if (part.type === 'data-sources') return ; + return null; +} + +function chatTransport(client: CopilotClient): DefaultChatTransport { + // The token is the only credential; the portal's cookies are never sent. + return new DefaultChatTransport({ + credentials: 'omit', + fetch: client.fetch, + prepareSendMessagesRequest: ({ messages, trigger }) => ({ + api: client.chatAddress(), + body: client.questionBody(messages, trigger) + }) + }); +} diff --git a/portal-template/src/components/without-chat-on-error.tsx b/portal-template/src/components/without-chat-on-error.tsx new file mode 100644 index 000000000..b907d179a --- /dev/null +++ b/portal-template/src/components/without-chat-on-error.tsx @@ -0,0 +1,14 @@ +import { Component, type ReactNode } from 'react'; + +/** Drops the chat if its chunk fails to load (say, after a redeploy), so the page stays up without Ask AI rather than failing whole. */ +export class WithoutChatOnError extends Component, { failed: boolean }> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + render() { + return this.state.failed ? null : this.props.children; + } +} diff --git a/portal-template/src/lib/copilot-client.ts b/portal-template/src/lib/copilot-client.ts new file mode 100644 index 000000000..db8dad812 --- /dev/null +++ b/portal-template/src/lib/copilot-client.ts @@ -0,0 +1,137 @@ +import type { PortalCopilot } from './portal-types'; + +/** Why Ask AI stays hidden: the service turned this portal, its key or this website away. */ +const TURNED_AWAY = new Set(['copilot_disabled', 'invalid_key', 'origin_not_allowed']); +/** A session is renewed this long before it expires, so a question never meets a stale one. */ +const RENEW_EARLY_MS = 30_000; + +interface Problem { + code?: string; + message?: string; +} + +/** The page a visitor asks from, as the browser reports it. */ +export interface AskingPage { + href: string; + title: string; +} + +/** The chat's own question, its page named without query or fragment, which can hold an OAuth callback's code. */ +export function questionMessage(text: string, { href, title }: AskingPage) { + const page = new URL(href); + return { + role: 'user' as const, + parts: [ + { type: 'data-client' as const, data: { location: `${page.origin}${page.pathname}`, title } }, + { type: 'text' as const, text } + ] + }; +} + +/** + * The Ask AI service as the chat panel sees it. The public key opens a short session; questions + * carry the session's token, never the key, and the service names the conversation's thread. + * These paths ship inside published portals, so the service must keep serving them; a breaking + * change needs a new `/v2` path. + */ +export class CopilotClient { + private session: Promise<{ token: string; expiresAt: number }> | null = null; + private thread: string | null = null; + + constructor( + private readonly copilot: Pick, + private readonly turnedAway: () => void, + private readonly send: typeof fetch = (input, init) => fetch(input, init) + ) {} + + readonly chatAddress = (): string => `${this.copilot.apiUrl}/v1/chat`; + + /** Opens the session as the panel opens, so a portal the service turns away hides Ask AI before anyone types. */ + readonly prepare = async (): Promise => { + await this.currentSession().catch(() => undefined); + }; + + /** The body for one question: only the latest message, since the service keeps the thread. */ + questionBody(messages: readonly unknown[], trigger: string): Record { + if (messages.length <= 1) this.thread = null; + return { threadId: this.thread, trigger, message: messages.at(-1) }; + } + + /** Remembers the thread the service started, from its `data-thread` part. */ + readonly noteData = (part: { type: string; data?: unknown }): void => { + if (part.type !== 'data-thread') return; + const threadId = (part.data as { threadId?: unknown } | undefined)?.threadId; + if (typeof threadId === 'string') this.thread = threadId; + }; + + /** + * The chat transport's fetch: adds the session token, renews an expired session once, and turns + * the service's error bodies into the message the panel shows. + */ + readonly fetch = async (input: RequestInfo | URL, init: RequestInit = {}): Promise => { + let response = await this.send(input, await this.authorised(init)); + if (response.status === 401) { + this.session = null; + response = await this.send(input, await this.authorised(init)); + } + if (response.ok) return response; + const problem = await problemOf(response); + if (TURNED_AWAY.has(problem.code ?? '')) this.turnedAway(); + return new Response(problem.message ?? 'Ask AI could not answer just now. Please try again shortly.', { + status: response.status + }); + }; + + private async authorised(init: RequestInit): Promise { + const { token } = await this.currentSession(); + const headers = new Headers(init.headers); + headers.set('authorization', `Bearer ${token}`); + return { ...init, headers, credentials: 'omit' }; + } + + private currentSession(): Promise<{ token: string; expiresAt: number }> { + const session = this.session; + if (session) { + return session.then((current) => (current.expiresAt - RENEW_EARLY_MS > Date.now() ? current : this.renew())); + } + return this.renew(); + } + + private renew(): Promise<{ token: string; expiresAt: number }> { + const opened = this.open(); + this.session = opened; + opened.catch(() => { + if (this.session === opened) this.session = null; + }); + return opened; + } + + private async open(): Promise<{ token: string; expiresAt: number }> { + let response: Response; + try { + response = await this.send(`${this.copilot.apiUrl}/v1/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ key: this.copilot.key }), + credentials: 'omit' + }); + } catch { + throw new Error('Ask AI is unavailable right now. Please try again later.'); + } + if (!response.ok) { + const problem = await problemOf(response); + if (TURNED_AWAY.has(problem.code ?? '')) this.turnedAway(); + throw new Error(problem.message ?? 'Ask AI is unavailable for this portal.'); + } + const { token, expiresAt } = (await response.json()) as { token: string; expiresAt: string }; + return { token, expiresAt: Date.parse(expiresAt) }; + } +} + +async function problemOf(response: Response): Promise { + try { + return (await response.json()) as Problem; + } catch { + return {}; + } +} diff --git a/portal-template/src/lib/copilot-entry.ts b/portal-template/src/lib/copilot-entry.ts new file mode 100644 index 000000000..7c4e4509f --- /dev/null +++ b/portal-template/src/lib/copilot-entry.ts @@ -0,0 +1,5 @@ +// Written by the APIMatic CLI from the 'portal' block of src/apimatic.json. Edit that instead. +// This portal offers no Ask AI chat, so nothing here imports it, and the build leaves it out. +import type CopilotChatComponent from '@/components/copilot'; + +export const CopilotChat: typeof CopilotChatComponent | null = null; diff --git a/portal-template/src/lib/layout.tsx b/portal-template/src/lib/layout.tsx index 34a01b494..14b233dc9 100644 --- a/portal-template/src/lib/layout.tsx +++ b/portal-template/src/lib/layout.tsx @@ -1,15 +1,65 @@ -import { useMemo, type ReactNode } from 'react'; +import { Suspense, useMemo, useState, type ReactNode } from 'react'; import type * as PageTree from 'fumadocs-core/page-tree'; import { DocsLayout as NotebookLayout } from 'fumadocs-ui/layouts/notebook'; +import type { AIChatOptions } from 'fumadocs-ui/layouts/shared'; +import { WithoutChatOnError } from '@/components/without-chat-on-error'; +import { CopilotChat } from './copilot-entry'; +import type { PortalCopilot } from './portal-types'; import { baseOptions } from './layout.shared'; +import { portal } from './portal'; import { portalTabs } from './tabs'; -export function PortalLayout({ tree, children }: Readonly<{ tree: PageTree.Root; children: ReactNode }>) { +type LayoutProps = Readonly<{ tree: PageTree.Root; children: ReactNode }>; + +export function PortalLayout({ tree, children }: LayoutProps) { + // Null for a portal without `portal.ai.copilot`, whose build then carries none of the chat. + if (CopilotChat === null || portal.copilot === null) return {children}; + return ( + + {children} + + ); +} + +type ChatProps = LayoutProps & Readonly<{ chat: NonNullable; copilot: PortalCopilot }>; + +/** The chat beside the layout, not around it, so a page hydrates without waiting for the chat's chunk. */ +function WithChat({ chat: Chat, copilot, tree, children }: ChatProps) { + const [open, setOpen] = useState(false); + const [panel, setPanel] = useState(null); + // `contents`, so the chat's panel lays out as the slot's own child. + const aiChat = useMemo( + () => ({ open, onOpenChange: setOpen, panel:
}), + [open] + ); + return ( + <> + + {children} + + {/* The prerender waits for the chunk, so each page carries the chat's button. */} + + + + + + + ); +} + +function Notebook({ tree, aiChat, children }: LayoutProps & { aiChat?: AIChatOptions }) { const base = baseOptions(); const tabs = useMemo(() => portalTabs(tree), [tree]); // The header spans the page, and `navbar` puts the tab switcher in it rather than the sidebar. return ( - + {children} ); diff --git a/portal-template/src/lib/llms.server.ts b/portal-template/src/lib/llms.server.ts index ce82f503b..cc4b68523 100644 --- a/portal-template/src/lib/llms.server.ts +++ b/portal-template/src/lib/llms.server.ts @@ -45,6 +45,27 @@ export async function renderFull(): Promise { return pages.join('\n\n'); } +/** A page as Ask AI reads it: its portal-relative path, as the chat links it, and its Markdown. */ +export interface AssistantPage { + path: string; + title: string; + description: string | null; + markdown: string; +} + +// Every page, those the navigation leaves out too, as `llms-full.txt` renders it: without the specifications. +export async function renderAssistantPages(): Promise { + return await Promise.all( + source.getPages().map(async (page) => ({ + path: page.url, + // The service names a page by its title in an answer's sources, so one without a title is named by its path. + title: page.data.title ?? page.url, + description: page.data.description ?? null, + markdown: await render(page, false) + })) + ); +} + async function render(page: PortalPage, withSpecification: boolean): Promise { return page.type === 'openapi' ? renderReference(page, withSpecification) : await renderContent(page); } diff --git a/portal-template/src/lib/portal-types.ts b/portal-template/src/lib/portal-types.ts index 8762b2eb2..5da901ef5 100644 --- a/portal-template/src/lib/portal-types.ts +++ b/portal-template/src/lib/portal-types.ts @@ -33,4 +33,15 @@ export interface Portal { links: PortalLink[]; /** Whether each page offers to open itself in an external AI assistant. */ pageActions: boolean; + /** The Ask AI chat, or null when the portal offers none. */ + copilot: PortalCopilot | null; +} + +export interface PortalCopilot { + /** A public key, bound to this portal and to the origins allowed to use it. */ + key: string; + /** The Ask AI service's origin. */ + apiUrl: string; + /** Starter questions on a new chat; empty for the chat's own. */ + suggestions: string[]; } diff --git a/portal-template/src/routes/api/copilot-pages[.]json.ts b/portal-template/src/routes/api/copilot-pages[.]json.ts new file mode 100644 index 000000000..145c3bc3e --- /dev/null +++ b/portal-template/src/routes/api/copilot-pages[.]json.ts @@ -0,0 +1,16 @@ +import { createFileRoute, notFound } from '@tanstack/react-router'; +import { renderAssistantPages } from '@/lib/llms.server'; +import { portal } from '@/lib/portal'; + +// Built only for a portal with Ask AI, for the CLI: `portal generate` bundles these pages for the +// service and removes this file, so it is never deployed. +export const Route = createFileRoute('/api/copilot-pages.json')({ + server: { + handlers: { + GET: async () => { + if (portal.copilot === null) throw notFound(); + return Response.json(await renderAssistantPages()); + } + } + } +}); diff --git a/portal-template/src/styles/app.css b/portal-template/src/styles/app.css index cef2f8ef3..c3d1b4093 100644 --- a/portal-template/src/styles/app.css +++ b/portal-template/src/styles/app.css @@ -5,8 +5,9 @@ /* Served with the site: a cross-origin font stylesheet would hold up first paint and hydration. */ @import '@fontsource-variable/geist'; @import '@fontsource-variable/geist-mono'; -/* Written by the CLI from the 'portal' block of apimatic.json: the colour overrides. Imported - last, so what it sets outranks the theme and presets above. */ +/* Written by the CLI from the 'portal' block of apimatic.json: the colour overrides, and the Ask AI + chat's preset for a portal that offers it. Imported last, so what it sets outranks the theme and + presets above. */ @import './theme.css'; /* The `font-sans` and `font-mono` classes read these, and the page defaults derive from them. */ diff --git a/portal-template/vite.config.ts b/portal-template/vite.config.ts index 57b4643cd..7e796b18f 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -17,7 +17,7 @@ import { staticImages } from './static-images.ts'; export default defineConfig(async ({ command, isPreview }) => { const [paths, identity] = await Promise.all([readBuildPaths(), readPortalIdentity()]); // `vite dev` prerenders nothing, and listing the pages bundles every spec: one that fails would stop it starting. - const pages = command === 'serve' && !isPreview ? [] : await prerenderPages(paths, identity.siteUrl); + const pages = command === 'serve' && !isPreview ? [] : await prerenderPages(paths, identity); const publicDir: string | false = paths.staticDir ?? false; const portalProjectDirectory = fileURLToPath(new URL('.', import.meta.url)); diff --git a/src/actions/portal/copilot/sync.ts b/src/actions/portal/copilot/sync.ts new file mode 100644 index 000000000..3086fc94b --- /dev/null +++ b/src/actions/portal/copilot/sync.ts @@ -0,0 +1,107 @@ +import { getAuthInfo } from '../../../client-utils/auth-manager.js'; +import { CopilotIngestService } from '../../../infrastructure/services/copilot-ingest-service.js'; +import { PortalCopilotSyncPrompts } from '../../../prompts/portal/copilot/sync.js'; +import { CommandMetadata } from '../../../types/common/command-metadata.js'; +import { DirectoryPath } from '../../../types/file/directoryPath.js'; +import { PLACEHOLDER_SITE } from '../../../types/portal/config/site-config.js'; +import { GeneratedPortalContext } from '../../../types/portal/copilot/generated-portal-context.js'; +import { ProjectContext } from '../../../types/project-context.js'; +import { ActionResult } from '../../action-result.js'; + +export class PortalCopilotSyncAction { + private readonly prompts = new PortalCopilotSyncPrompts(); + private readonly ingestService = new CopilotIngestService(); + + constructor( + private readonly configDir: DirectoryPath, + private readonly commandMetadata: CommandMetadata, + private readonly authKey: string | null = null + ) {} + + public readonly execute = async ( + project: ProjectContext, + portalDirectory: DirectoryPath, + wait: boolean + ): Promise => { + // The site's name is never synced, so the specifications need not be read to default it. + const settings = await project.portalSource().resolveSettings(PLACEHOLDER_SITE); + if (settings.isErr()) { + this.prompts.sourceProblem(settings.error, project.sourceDirectory()); + return ActionResult.failed(); + } + const { config } = settings.value; + + const copilot = config.copilotService(); + if (copilot === null) { + this.prompts.copilotNotConfigured(project.sourceDirectory()); + return ActionResult.failed(); + } + const authKey = await this.uploadKey(); + if (authKey === null) { + return ActionResult.failed(); + } + + // As `generate` wrote it for the deploy, under the address the portal was built for. + const bundled = await new GeneratedPortalContext(portalDirectory).bundle(); + if (bundled.isErr()) { + this.prompts.portalNotRead(portalDirectory, bundled.error); + return ActionResult.failed(); + } + const bundle = bundled.value; + if (bundle === null) { + this.prompts.portalNotGenerated(portalDirectory); + return ActionResult.failed(); + } + + const { shell } = this.commandMetadata; + const receipt = await this.prompts.upload( + this.ingestService.upload(copilot, bundle, shell, authKey), + bundle.pageCount() + ); + if (receipt.isErr()) { + this.prompts.uploadFailed(receipt.error, copilot.address); + return ActionResult.failed(); + } + if (receipt.value.status === 'unchanged') { + this.prompts.alreadyUpToDate(receipt.value.pageCount); + return ActionResult.success(); + } + if (!wait) { + this.prompts.indexingQueued(); + return ActionResult.success(); + } + + const indexed = await this.prompts.awaitIndexing( + this.ingestService.awaitIndexing(copilot, receipt.value.versionId, shell, authKey) + ); + if (indexed.isErr()) { + this.prompts.indexingFailed(indexed.error, copilot.address); + return ActionResult.failed(); + } + if (indexed.value.status === 'superseded') { + this.prompts.superseded(); + } else { + this.prompts.indexed(indexed.value); + } + return ActionResult.success(); + }; + + /** + * The only place that decides which key the service receives: the passed one, else the saved login. The + * service is APIMatic's own, or one on this machine, as the `portal` block allows no other. + */ + private async uploadKey(): Promise { + // A blank `--auth-key` (say, from an unset variable) is no key, so it falls back to the saved login. + const passed = this.authKey?.trim() ?? ''; + if (passed !== '') { + return passed; + } + // `auth logout` blanks the stored key rather than deleting the file, so the key is checked, not the file. + const saved = (await getAuthInfo(this.configDir.toString()))?.authKey; + if (!saved) { + this.prompts.notLoggedIn(); + return null; + } + return saved; + } +} diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 2a1f42b3b..63be1a877 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -1,3 +1,4 @@ +import { envInfo } from '../../infrastructure/env-info.js'; import { PortalBuildService } from '../../infrastructure/portal-build-service.js'; import { PortalProjectPaths } from '../../infrastructure/portal-project-service.js'; import { PortalGeneratePrompts } from '../../prompts/portal/generate.js'; @@ -5,6 +6,8 @@ import { CommandMetadata } from '../../types/common/command-metadata.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; import { PortalContext } from '../../types/portal-context.js'; import { SiteAddress } from '../../types/portal/config/site-address.js'; +import { CopilotBundle } from '../../types/portal/copilot/copilot-bundle.js'; +import { GeneratedPortalContext } from '../../types/portal/copilot/generated-portal-context.js'; import { ProjectContext } from '../../types/project-context.js'; import { ActionResult } from '../action-result.js'; import { PreparePortalProjectAction } from './prepare-project.js'; @@ -44,7 +47,9 @@ export class GenerateAction { return await new PreparePortalProjectAction(this.configDir, this.commandMetadata, this.authKey).execute(project, { confirm: () => this.confirmOverwrite(portalContext, portalDirectory, force), onPrepared: (portalProject, source) => - this.build(portalProject, source.config.siteAddress(), portalContext, portalDirectory, zipPortal) + this.build(portalProject, source.config.siteAddress(), portalContext, portalDirectory, zipPortal, { + copilot: source.config.identity().copilot !== null + }) }); }; @@ -65,7 +70,8 @@ export class GenerateAction { site: SiteAddress | null, portalContext: PortalContext, portalDirectory: DirectoryPath, - zipPortal: boolean + zipPortal: boolean, + { copilot }: { copilot: boolean } ): Promise { const build = await this.prompts.buildPortal(this.buildService.build(portalProject)); @@ -76,6 +82,9 @@ export class GenerateAction { return ActionResult.failed(); } + // Added before saving, so a zipped portal carries the bundle too. + const copilotPublished = copilot && (await this.publishCopilotBundle(build.value.output, site)); + const saved = await this.prompts.savePortal(portalContext.save(build.value.output, zipPortal)); if (saved.isErr()) { return ActionResult.failed(); @@ -83,7 +92,33 @@ export class GenerateAction { this.prompts.portalGenerated(portalDirectory); this.prompts.nextSteps(portalDirectory, zipPortal, site); + if (copilotPublished) this.prompts.copilotUpdatesAfterDeploy(site); return ActionResult.success(); } + + /** The portal still saves without the bundle, whatever stops it. */ + private async publishCopilotBundle(builtDirectory: DirectoryPath, site: SiteAddress | null): Promise { + const portal = new GeneratedPortalContext(builtDirectory); + const taken = await portal.takePages(); + if (taken.isErr()) { + this.prompts.copilotBundleNotWritten(taken.error); + return false; + } + const pages = taken.value; + if (pages === null) { + this.prompts.copilotPagesNotBuilt(); + return false; + } + if (pages.length === 0) { + this.prompts.copilotPagesNotFound(); + return false; + } + const published = await portal.publish(CopilotBundle.of(pages, site, envInfo.getCLIVersion(), new Date())); + if (published.isErr()) { + this.prompts.copilotBundleNotWritten(published.error); + return false; + } + return true; + } } diff --git a/src/commands/portal/copilot/sync.ts b/src/commands/portal/copilot/sync.ts new file mode 100644 index 000000000..d6eaf701f --- /dev/null +++ b/src/commands/portal/copilot/sync.ts @@ -0,0 +1,73 @@ +import { Command, Flags } from '@oclif/core'; +import { PortalCopilotSyncAction } from '../../../actions/portal/copilot/sync.js'; +import { TelemetryService } from '../../../infrastructure/services/telemetry-service.js'; +import { format, intro, outro } from '../../../prompts/format.js'; +import { CommandMetadata } from '../../../types/common/command-metadata.js'; +import { PortalCopilotSyncFailedEvent } from '../../../types/events/portal-copilot-sync-failed.js'; +import { DirectoryPath } from '../../../types/file/directoryPath.js'; +import { FlagsProvider } from '../../../types/flags-provider.js'; +import { ProjectContext } from '../../../types/project-context.js'; + +export default class PortalCopilotSync extends Command { + static readonly summary = "Update the portal's Ask AI chat with the portal's current pages."; + + static readonly description = `Uploads the pages of a generated portal to the Ask AI service, so the portal's Ask AI chat answers from the docs as they are now. + +The service picks up the pages by itself from a portal deployed at its 'site.url'. Run this command after deploying a portal generated with 'apimatic portal generate' when it cannot: for a portal without 'site.url', or one the service cannot reach, such as one on a private network. + +The service is set by the 'ai.copilot' setting in the 'portal' block of 'src/apimatic.json': APIMatic's Ask AI service, or one on this machine. The pages are swapped in at once, after all of them are indexed; until then Ask AI answers from the previous ones. + +The upload is made with your saved APIMatic login, or with the auth key passed with --auth-key.`; + + static readonly cmdTxt = format.cmd('apimatic', 'portal', 'copilot', 'sync'); + + static readonly examples = [ + PortalCopilotSync.cmdTxt, + `${PortalCopilotSync.cmdTxt} ${format.flag('input', './')} ${format.flag('destination', './portal')}`, + `${PortalCopilotSync.cmdTxt} ${format.flag('no-wait')}` + ]; + + static readonly flags = { + wait: Flags.boolean({ + default: true, + allowNo: true, + description: 'wait until the pages are indexed; with --no-wait, return as soon as they are uploaded.' + }), + destination: Flags.string({ + char: 'd', + description: '[default: /portal] path to the generated portal to sync.' + }), + ...FlagsProvider.input, + ...FlagsProvider.authKey + }; + + async run(): Promise { + const { flags } = await this.parse(PortalCopilotSync); + const { input, destination, wait, 'auth-key': authKey } = flags; + + const project = ProjectContext.at(input); + const commandMetadata: CommandMetadata = { + commandName: PortalCopilotSync.id, + shell: this.config.shell + }; + + intro('Sync Ask AI'); + const action = new PortalCopilotSyncAction(this.getConfigDir(), commandMetadata, authKey); + const result = await action.execute(project, project.portalDirectory(destination), wait); + outro(result); + + await result.mapAll( + async () => {}, + async () => + await new TelemetryService(this.getConfigDir()).trackEvent( + new PortalCopilotSyncFailedEvent(result.getMessage(), PortalCopilotSync.id, flags), + commandMetadata.shell + ), + async () => {} + ); + } + + private readonly getConfigDir = () => { + return new DirectoryPath(this.config.configDir); + }; +} diff --git a/src/infrastructure/generation-status-poller.ts b/src/infrastructure/generation-status-poller.ts index 29d7aad0f..682e26b9f 100644 --- a/src/infrastructure/generation-status-poller.ts +++ b/src/infrastructure/generation-status-poller.ts @@ -21,25 +21,39 @@ export interface GenerationStatus { export type ValidationErrorFormatter = (errors: Record) => string; +/** + * Bounds a run whose status never settles. `label` opens the message. + * The budget is only read between polls, so `fetchStatus` must bound its own request — + * one that connects and never answers outlives any budget set here. + */ +export interface PollTimeout { + budgetMs: number; + label: string; +} + export interface GenerationPoll { pollIntervalMs: number; fetchStatus: () => Promise>; - /** - * Bounds a run whose status never reaches a terminal value. `label` opens the message. - * The budget is only read between polls, so `fetchStatus` must bound its own request — - * one that connects and never answers outlives any budget set here. - */ - timeout: { budgetMs: number; label: string }; + timeout: PollTimeout; /** Only when the endpoint needs custom validation wording. */ formatValidationError?: ValidationErrorFormatter; } -export async function pollUntilCompleted({ +export interface StatusPoll { + pollIntervalMs: number; + fetchStatus: () => Promise>; + timeout: PollTimeout; + /** What a status ends the run with, or null while the run is still going. */ + settle: (status: S) => Result | null; +} + +/** Polls until `settle` ends the run, or the timeout does. */ +export async function pollUntilSettled({ pollIntervalMs, fetchStatus, timeout, - formatValidationError = formatValidationErrors -}: GenerationPoll): Promise> { + settle +}: StatusPoll): Promise> { const deadline = Date.now() + timeout.budgetMs; for (;;) { @@ -50,34 +64,55 @@ export async function pollUntilCompleted({ return err(statusResult.error); } - const { status, errors } = statusResult.value; - - if (status === Status.Completed) { - return ok(statusResult.value); - } - if (status === Status.Failed) { - return err(ServiceError.ServerError); - } - if (status === Status.ValidationError) { - const validationErrors = asMessages(errors); - return err(ServiceError.badRequest(formatValidationError(validationErrors), validationErrors)); - } - if (status === Status.SubscriptionError) { - const message = Object.values(asMessages(errors)).flat()[0]; - return err(ServiceError.forbidden('Access denied to resource.' + (message ? '\n' + bulleted([message]) : ''))); + const settled = settle(statusResult.value); + if (settled !== null) { + return settled; } - // Every other status keeps the run alive rather than ending it: an endpoint reporting - // its own in-flight vocabulary is healthy, and the plugin orchestrator reports - // `Unknown` until it writes a custom status. The deadline is what stops a genuinely - // stuck run, and it is read after the status so a run that finished during the last - // wait still counts. + // The deadline is what stops a genuinely stuck run, and it is read after the status + // so a run that finished during the last wait still counts. if (Date.now() >= deadline) { return err(ServiceError.timeout(timedOutMessage(timeout.label, timeout.budgetMs))); } } } +export async function pollUntilCompleted({ + pollIntervalMs, + fetchStatus, + timeout, + formatValidationError = formatValidationErrors +}: GenerationPoll): Promise> { + return pollUntilSettled({ + pollIntervalMs, + fetchStatus, + timeout, + settle: (current): Result | null => { + const { status, errors } = current; + + if (status === Status.Completed) { + return ok(current); + } + if (status === Status.Failed) { + return err(ServiceError.ServerError); + } + if (status === Status.ValidationError) { + const validationErrors = asMessages(errors); + return err(ServiceError.badRequest(formatValidationError(validationErrors), validationErrors)); + } + if (status === Status.SubscriptionError) { + const message = Object.values(asMessages(errors)).flat()[0]; + return err(ServiceError.forbidden('Access denied to resource.' + (message ? '\n' + bulleted([message]) : ''))); + } + + // Every other status keeps the run alive rather than ending it: an endpoint reporting + // its own in-flight vocabulary is healthy, and the plugin orchestrator reports + // `Unknown` until it writes a custom status. + return null; + } + }); +} + /** Each message under a bullet of its own, any further line of it indented beneath. */ export const bulleted = (messages: string[]): string => messages.map((message) => `- ${message.replaceAll('\n', '\n ')}`).join('\n'); diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index ab2622372..89f5ac115 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -14,6 +14,7 @@ import { PortalConfig } from '../types/portal/portal-config.js'; import { PLUGIN_DOWNLOAD, SDK_DOWNLOADS_FOLDER, sdkDownload } from '../types/portal/portal-downloads.js'; import { PortalSettings, PortalSource } from '../types/portal/portal-source.js'; import { PortalStylesheet } from '../types/portal/portal-stylesheet.js'; +import { PortalCopilotEntry } from '../types/portal/portal-copilot-entry.js'; import { errorMessage } from '../utils/error-utils.js'; import { envInfo } from './env-info.js'; import { FileService } from './file-service.js'; @@ -27,6 +28,8 @@ export const COPIED_DEPENDENCIES = ['@fontsource-variable/geist', '@fontsource-v // pnpm global install, `npx` or `pnpm dlx` the package has no nested `node_modules`, and a // single link also lets Vite write its scratch files into the CLI's own install directory. export const LINKED_DEPENDENCIES = [ + '@ai-sdk/react', + '@fumadocs/ai-chat', '@fumadocs/api-docs', '@scalar/json-magic', '@tailwindcss/vite', @@ -34,6 +37,7 @@ export const LINKED_DEPENDENCIES = [ '@tanstack/react-start', '@tanstack/start-static-server-functions', '@vitejs/plugin-react', + 'ai', 'fumadocs-core', 'fumadocs-mdx', 'fumadocs-openapi', @@ -57,6 +61,8 @@ const IDENTITY_FILE_NAME = 'portal.identity.json'; /** In `src/styles/`, beside `app.css`, which imports it. */ const STYLESHEET_FILE_NAME = 'theme.css'; +/** Beside `layout.tsx`, which takes the Ask AI chat from it. */ +const COPILOT_ENTRY_FILE_NAME = 'copilot-entry.ts'; /** Where the SDKs and the context plugin are laid out as the site serves them, inside the project. */ export const DOWNLOADS_DIRECTORY_NAME = 'downloads'; @@ -389,6 +395,10 @@ export class PortalProjectService { [ new FilePath(projectDirectory.join('src').join('styles'), new FileName(STYLESHEET_FILE_NAME)), PortalStylesheet.of(config).toString() + ], + [ + new FilePath(projectDirectory.join('src').join('lib'), new FileName(COPILOT_ENTRY_FILE_NAME)), + PortalCopilotEntry.of(config).toString() ] ]; } diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index 313922aff..fe224d0c4 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -11,7 +11,8 @@ export enum ServiceErrorCode { UnAuthorized = 'UNAUTHORIZED', BadRequest = 'BAD_REQUEST', Forbidden = 'FORBIDDEN', - Timeout = 'TIMEOUT' + Timeout = 'TIMEOUT', + ProcessingFailed = 'PROCESSING_FAILED' } export class ServiceError { @@ -40,6 +41,10 @@ export class ServiceError { static timeout(customMessage: string): ServiceError { return new ServiceError(ServiceErrorCode.Timeout, customMessage, {}); } + /** The service took the work on but could not finish it; the message is its reason, or empty without one. */ + static processingFailed(reason: string | null): ServiceError { + return new ServiceError(ServiceErrorCode.ProcessingFailed, reason ?? '', {}); + } static unauthorizedWithHint(apiMessage: string | null): ServiceError { // Both remedies name the full `auth login` command: the key is supplied to // that command, not to whichever one hit the 401 — most of them don't accept diff --git a/src/infrastructure/services/copilot-ingest-service.ts b/src/infrastructure/services/copilot-ingest-service.ts new file mode 100644 index 000000000..6527c26d8 --- /dev/null +++ b/src/infrastructure/services/copilot-ingest-service.ts @@ -0,0 +1,181 @@ +import axios, { AxiosResponse } from 'axios'; +import { err, ok, Result } from 'neverthrow'; +import { CopilotService } from '../../types/portal/config/copilot-config.js'; +import { CopilotBundle } from '../../types/portal/copilot/copilot-bundle.js'; +import { IndexedVersion, IndexingState, IngestReceipt, IngestStatus } from '../../types/portal/copilot/ingest.js'; +import { isJsonObject } from '../../utils/json-utils.js'; +import { envInfo } from '../env-info.js'; +import { pollUntilSettled } from '../generation-status-poller.js'; +import { handleServiceError, ServiceError } from '../service-error.js'; + +export const COPILOT_INGEST_TIMINGS = { + pollIntervalMs: 2000, + indexTimeoutMs: 10 * 60 * 1000, + uploadTimeoutMs: 120_000, + statusTimeoutMs: 15_000 +}; + +export type CopilotIngestTimings = Partial; + +/** All the service needs of the portal's Ask AI settings: where it is, and which portal's chat it is. */ +export type CopilotTarget = CopilotService; + +const STATES = new Set(['queued', 'building', 'active', 'superseded', 'failed']); + +/** Uploads a portal's pages to the Ask AI service, which swaps them in once it has indexed them all. */ +export class CopilotIngestService { + private readonly timings: typeof COPILOT_INGEST_TIMINGS; + + constructor(timings: CopilotIngestTimings = {}) { + this.timings = { ...COPILOT_INGEST_TIMINGS, ...timings }; + } + + public async upload( + copilot: CopilotTarget, + bundle: CopilotBundle, + shell: string, + authKey: string + ): Promise> { + try { + const response = await this.axiosInstance(copilot, shell, authKey, this.timings.uploadTimeoutMs).post( + '/v1/ingest', + bundle.gzipped(), + { + headers: { 'Content-Type': 'application/gzip' }, + // The service owns the size limit, and answers 413 past it. + maxBodyLength: Infinity + } + ); + if (response.status !== 200 && response.status !== 202) { + return err(failureOf(response)); + } + const receipt = response.data as unknown; + return isReceipt(receipt) ? ok(receipt) : err(ServiceError.InvalidResponse); + } catch (error) { + const timedOut = `The upload did not finish within ${duration(this.timings.uploadTimeoutMs)}.`; + return err(transportFailure(error, timedOut)); + } + } + + /** Waits for the uploaded version to finish indexing; a failed one is an error carrying the service's reason. */ + public async awaitIndexing( + copilot: CopilotTarget, + versionId: string, + shell: string, + authKey: string + ): Promise> { + return pollUntilSettled({ + pollIntervalMs: this.timings.pollIntervalMs, + fetchStatus: () => this.status(copilot, versionId, shell, authKey), + timeout: { budgetMs: this.timings.indexTimeoutMs, label: 'Indexing' }, + settle: (current): Result | null => { + const { status, error } = current; + if (status === 'failed') { + return err(ServiceError.processingFailed(error)); + } + return status === 'queued' || status === 'building' ? null : ok({ ...current, status }); + } + }); + } + + private async status( + copilot: CopilotTarget, + versionId: string, + shell: string, + token: string + ): Promise> { + try { + const response = await this.axiosInstance(copilot, shell, token, this.timings.statusTimeoutMs).get( + `/v1/ingest/${encodeURIComponent(versionId)}` + ); + if (response.status !== 200) { + return err(failureOf(response)); + } + const status = statusOf(response.data); + return status === null ? err(ServiceError.InvalidResponse) : ok(status); + } catch (error) { + const timedOut = `The Ask AI service did not answer within ${duration(this.timings.statusTimeoutMs)}.`; + return err(transportFailure(error, timedOut)); + } + } + + private axiosInstance(copilot: CopilotTarget, shell: string, token: string, timeout: number) { + return axios.create({ + baseURL: copilot.address.toString(), + timeout, + validateStatus: () => true, + // A request that runs out of time then fails as ETIMEDOUT, apart from one that never got through. + transitional: { clarifyTimeoutError: true }, + headers: { + 'User-Agent': envInfo.getUserAgent(shell), + Authorization: `X-Auth-Key ${token}`, + 'X-Copilot-Key': copilot.key + } + }); + } +} + +/** The service explains every refusal as `{ code, message }`; the message is what the user is shown. */ +function failureOf(response: AxiosResponse): ServiceError { + const body = response.data as unknown; + const message = isJsonObject(body) && typeof body.message === 'string' ? body.message : null; + switch (response.status) { + case 400: + case 413: + return message === null ? ServiceError.InvalidResponse : ServiceError.badRequest(message, {}); + case 401: + return ServiceError.unauthorizedWithHint(message); + case 403: + return ServiceError.forbidden(message ?? 'Access denied.'); + case 404: + return message === null ? ServiceError.NotFound : ServiceError.notFound(message); + default: + return response.status >= 500 ? ServiceError.ServerError : ServiceError.InvalidResponse; + } +} + +// Every request answers with its status, so an axios error here is one that never got an answer. +function transportFailure(error: unknown, timedOut: string): ServiceError { + if (!axios.isAxiosError(error) || error.response !== undefined) { + return handleServiceError(error); + } + return error.code === 'ETIMEDOUT' ? ServiceError.timeout(timedOut) : ServiceError.NetworkError; +} + +function duration(ms: number): string { + if (ms < 60_000) { + const seconds = Math.max(1, Math.round(ms / 1000)); + return `${seconds} ${seconds === 1 ? 'second' : 'seconds'}`; + } + const minutes = Math.round(ms / 60_000); + return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; +} + +function isReceipt(body: unknown): body is IngestReceipt { + return ( + isJsonObject(body) && + typeof body.versionId === 'string' && + (body.status === 'queued' || body.status === 'unchanged') && + typeof body.pageCount === 'number' + ); +} + +// Only the state is needed to decide; the counts are reported, and a missing one reads as none. +function statusOf(body: unknown): IngestStatus | null { + if (!isJsonObject(body) || typeof body.versionId !== 'string' || !STATES.has(body.status as IndexingState)) { + return null; + } + const count = (key: string): number => { + const value = body[key]; + return typeof value === 'number' ? value : 0; + }; + return { + versionId: body.versionId, + status: body.status as IndexingState, + pageCount: count('pageCount'), + changedPages: count('changedPages'), + removedPages: count('removedPages'), + chunkCount: count('chunkCount'), + error: typeof body.error === 'string' && body.error !== '' ? body.error : null + }; +} diff --git a/src/infrastructure/zip-service.ts b/src/infrastructure/zip-service.ts index 08b6f3742..3dea906df 100644 --- a/src/infrastructure/zip-service.ts +++ b/src/infrastructure/zip-service.ts @@ -29,6 +29,16 @@ export class ZipService { } } + /** One file of an archive, as text, without expanding the rest; null for an archive without it. */ + public readEntry(sourceFile: FilePath, entryPath: string): Result { + try { + const entry = new AdmZip(sourceFile.toString()).getEntry(entryPath); + return ok(entry === null ? null : entry.getData().toString('utf8')); + } catch (error) { + return err(errorMessage(error)); + } + } + private async write(sourceDir: DirectoryPath, outputZipPath: FilePath): Promise { const entries = this.entriesUnder(sourceDir, ''); const zipfile = new yazl.ZipFile(); diff --git a/src/prompts/portal/copilot/sync.ts b/src/prompts/portal/copilot/sync.ts new file mode 100644 index 000000000..8771e764a --- /dev/null +++ b/src/prompts/portal/copilot/sync.ts @@ -0,0 +1,127 @@ +import { log } from '@clack/prompts'; +import { Result } from 'neverthrow'; +import { ServiceError, ServiceErrorCode } from '../../../infrastructure/service-error.js'; +import { APIMATIC_CONFIG_FILE_NAME } from '../../../types/apimatic-config/document.js'; +import { DirectoryPath } from '../../../types/file/directoryPath.js'; +import { ServiceAddress } from '../../../types/portal/config/service-address.js'; +import { FileName } from '../../../types/file/fileName.js'; +import { FilePath } from '../../../types/file/filePath.js'; +import { IndexedVersion, IngestReceipt } from '../../../types/portal/copilot/ingest.js'; +import { PortalSourceProblem } from '../../../types/portal/portal-source.js'; +import { format as f } from '../../format.js'; +import { withSpinner } from '../../prompt.js'; +import { reportSourceProblem } from '../source.js'; + +const COPILOT_EXAMPLE = '"ai": { "copilot": { "key": "pk_…", "apiUrl": "https://ask.apimatic.io" } }'; + +const pages = (count: number): string => `${count} ${count === 1 ? 'page' : 'pages'}`; + +export class PortalCopilotSyncPrompts { + public sourceProblem(problem: PortalSourceProblem, sourceDirectory: DirectoryPath) { + reportSourceProblem(problem, sourceDirectory); + } + + public copilotNotConfigured(sourceDirectory: DirectoryPath) { + const config = new FilePath(sourceDirectory, new FileName(APIMATIC_CONFIG_FILE_NAME)); + const message = + `The portal has no Ask AI chat to sync. Add ${f.var(COPILOT_EXAMPLE)} to the ${f.var('portal')} block of ` + + `${f.path(config)}, then generate and deploy the portal again.`; + log.error(message); + } + + public notLoggedIn() { + log.error(ServiceError.unauthorizedWithHint(null).errorMessage); + } + + public portalNotGenerated(portalDirectory: DirectoryPath) { + const message = + `No pages for Ask AI were found in the portal at ${f.path(portalDirectory)}. ` + + `Generate the portal with its Ask AI chat set up first: ${f.cmdAlt('apimatic', 'portal', 'generate')}.`; + log.error(message); + } + + public portalNotRead(portalDirectory: DirectoryPath, reason: string) { + log.error(`The portal at ${f.path(portalDirectory)} could not be read (${reason}).`); + } + + public upload(fn: Promise>, pageCount: number) { + return withSpinner( + `Uploading ${pages(pageCount)} to Ask AI`, + ({ status }) => (status === 'unchanged' ? 'Uploaded; nothing has changed.' : 'Uploaded.'), + 'Upload failed.', + fn + ); + } + + public awaitIndexing(fn: Promise>) { + return withSpinner( + 'Indexing the pages', + ({ status }) => (status === 'active' ? 'Indexed.' : 'Replaced by a newer sync.'), + 'Indexing did not finish.', + fn, + { indicator: 'timer' } + ); + } + + public uploadFailed(error: ServiceError, service: ServiceAddress) { + this.syncFailed( + error, + service, + `${error.errorMessage} The service may have received the pages all the same; sync again, and it answers ` + + `that nothing has changed if it did.` + ); + } + + public indexingFailed(error: ServiceError, service: ServiceAddress) { + this.syncFailed( + error, + service, + `${error.errorMessage} The service keeps indexing, and Ask AI answers from the previous pages until it ` + + `is done; check the portal's Ask AI again in a few minutes.` + ); + } + + private syncFailed(error: ServiceError, service: ServiceAddress, timedOut: string) { + switch (error.code) { + case ServiceErrorCode.NetworkError: + log.error( + `The Ask AI service at ${f.link(service.toString())} could not be reached. Check the address and try again.` + ); + return; + case ServiceErrorCode.Forbidden: + log.error( + `${error.errorMessage} The account of the auth key must own the portal that the Ask AI key belongs to.` + ); + return; + case ServiceErrorCode.Timeout: + log.error(timedOut); + return; + case ServiceErrorCode.ProcessingFailed: { + const reason = error.errorMessage === '' ? '.' : `: ${error.errorMessage}`; + log.error( + `The Ask AI service could not index the pages${reason} ` + + `Ask AI answers from the previous pages until a sync is indexed.` + ); + return; + } + default: + log.error(error.errorMessage); + } + } + + public alreadyUpToDate(pageCount: number) { + log.info(`Ask AI is already up to date with the portal's ${pages(pageCount)}.`); + } + + public indexingQueued() { + log.info('Ask AI answers from the new pages once the service has indexed them.'); + } + + public indexed({ pageCount, changedPages, removedPages }: IndexedVersion) { + log.info(`Ask AI now answers from ${pages(pageCount)} (${changedPages} changed, ${removedPages} removed).`); + } + + public superseded() { + log.warn('A newer sync replaced this one before it was indexed, so Ask AI answers from that one.'); + } +} diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 58f4d49e6..206fd87bd 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -113,4 +113,43 @@ export class PortalGeneratePrompts { ].join('\n'); noteWrapped(message, 'Next steps'); } + + /** + * The service fetches a deployed portal's pages from the portal's address, so only a portal with one + * updates Ask AI by itself; `sync` is the way for the rest. + */ + public copilotUpdatesAfterDeploy(site: SiteAddress | null) { + if (site === null) { + log.info( + `Deploy the portal, then run ${syncCommand()} so Ask AI answers from these pages: without ` + + `${f.var('site.url')}, the Ask AI service cannot find the portal by itself.` + ); + return; + } + log.info( + `Ask AI picks up these pages by itself once the portal is deployed at ${f.link(site.toString())}. ` + + `If the Ask AI service cannot reach it there, run ${syncCommand()} after deploying instead.` + ); + } + + public copilotPagesNotFound() { + log.warn('The portal has no pages for Ask AI to answer from, so none were added to it.'); + } + + // A fault of the build rather than of the docs, so the author is not sent looking through their pages. + public copilotPagesNotBuilt() { + log.warn( + 'The build wrote no pages for Ask AI, so none were added to the portal. Generate the portal again, ' + + `and if they are still missing, contact ${f.var('support@apimatic.io')}.` + ); + } + + public copilotBundleNotWritten(reason: string) { + log.warn( + `The pages for Ask AI could not be added to the portal (${reason}), so it is saved without them. ` + + `Generate the portal again for Ask AI to answer from it.` + ); + } } + +const syncCommand = () => f.cmdAlt('apimatic', 'portal', 'copilot', 'sync'); diff --git a/src/types/events/portal-copilot-sync-failed.ts b/src/types/events/portal-copilot-sync-failed.ts new file mode 100644 index 000000000..3d1e1d6f3 --- /dev/null +++ b/src/types/events/portal-copilot-sync-failed.ts @@ -0,0 +1,9 @@ +import { DomainEvent } from './domain-event.js'; + +export class PortalCopilotSyncFailedEvent extends DomainEvent { + protected readonly eventName = PortalCopilotSyncFailedEvent.name; + + constructor(message: string, commandName: string, flags: Record) { + super(message, commandName, flags); + } +} diff --git a/src/types/portal/config/ai-config.ts b/src/types/portal/config/ai-config.ts index d188c0552..c0e3d00e0 100644 --- a/src/types/portal/config/ai-config.ts +++ b/src/types/portal/config/ai-config.ts @@ -1,21 +1,23 @@ import { Result } from 'neverthrow'; -import { allOf, namespace, optionalBoolean, Parsed, unknownKeys } from './fields.js'; +import { CopilotConfig, CopilotService, PortalCopilot } from './copilot-config.js'; +import { allOf, namespace, optional, optionalBoolean, Parsed, unknownKeys } from './fields.js'; -const KNOWN = ['pageActions']; +const KNOWN = ['pageActions', 'copilot']; export class AiConfig { - private constructor(private readonly pageActions: boolean) {} + private constructor(private readonly pageActions: boolean, private readonly copilotSettings: CopilotConfig | null) {} - public static readonly defaults = new AiConfig(true); + public static readonly defaults = new AiConfig(true, null); public static parse(value: unknown, path: string): Parsed { return namespace(value, path).andThen((data) => allOf( unknownKeys(data, KNOWN, path), Result.combineWithAllErrors([ - optionalBoolean(data.pageActions, `${path}.pageActions`, AiConfig.defaults.pageActions) + optionalBoolean(data.pageActions, `${path}.pageActions`, AiConfig.defaults.pageActions), + optional(data.copilot, (present) => CopilotConfig.parse(present, `${path}.copilot`)) ]) - ).map(([pageActions]) => new AiConfig(pageActions)) + ).map(([pageActions, copilot]) => new AiConfig(pageActions, copilot)) ); } @@ -27,7 +29,20 @@ export class AiConfig { return this.pageActions; } - public toJSON(): { pageActions: boolean } { - return { pageActions: this.pageActions }; + /** The Ask AI chat the portal offers, or null when it offers none. */ + public copilot(): PortalCopilot | null { + return this.copilotSettings?.identity() ?? null; + } + + /** The service the Ask AI chat is on, or null when the portal offers none. */ + public copilotService(): CopilotService | null { + return this.copilotSettings?.service() ?? null; + } + + public toJSON(): { pageActions: boolean; copilot?: ReturnType } { + return { + pageActions: this.pageActions, + ...(this.copilotSettings === null ? {} : { copilot: this.copilotSettings.toJSON() }) + }; } } diff --git a/src/types/portal/config/copilot-config.ts b/src/types/portal/config/copilot-config.ts new file mode 100644 index 000000000..d7e3f0d36 --- /dev/null +++ b/src/types/portal/config/copilot-config.ts @@ -0,0 +1,75 @@ +import { err, ok, Result } from 'neverthrow'; +import { allOf, namespace, nonEmptyString, optional, Parsed, unknownKeys } from './fields.js'; +import { ServiceAddress } from './service-address.js'; + +const KNOWN = ['key', 'apiUrl', 'suggestions']; +const MAX_SUGGESTIONS = 3; +const MAX_SUGGESTION_LENGTH = 120; + +export interface PortalCopilot { + key: string; + /** The Ask AI service's origin. */ + apiUrl: string; + /** Starter questions on a new chat; empty for the chat's own. */ + suggestions: string[]; +} + +type CopilotBlock = Omit & { suggestions?: string[] }; + +/** What `sync` needs of the Ask AI settings: which portal's chat it is, and the service it is on. */ +export interface CopilotService { + key: string; + address: ServiceAddress; +} + +export class CopilotConfig { + private constructor( + private readonly key: string, + private readonly address: ServiceAddress, + private readonly suggestions: string[] + ) {} + + public static parse(value: unknown, path: string): Parsed { + return namespace(value, path).andThen((data) => + allOf( + unknownKeys(data, KNOWN, path), + // The key is not checked here: the Ask AI service validates it, and its design may change. + Result.combineWithAllErrors([ + nonEmptyString(data.key, `${path}.key`), + // Required while there is no hosted service; once there is, default to its address and keep this as an override. + ServiceAddress.parse(data.apiUrl, `${path}.apiUrl`), + optional(data.suggestions, (present) => suggestionList(present, `${path}.suggestions`)) + ]) + ).map(([key, address, suggestions]) => new CopilotConfig(key, address, suggestions ?? [])) + ); + } + + /** As the browser is told it. */ + public identity(): PortalCopilot { + return { key: this.key, apiUrl: this.address.toString(), suggestions: this.suggestions }; + } + + public service(): CopilotService { + return { key: this.key, address: this.address }; + } + + public toJSON(): CopilotBlock { + return { + key: this.key, + apiUrl: this.address.toString(), + ...(this.suggestions.length > 0 ? { suggestions: this.suggestions } : {}) + }; + } +} + +function suggestionList(value: unknown, path: string): Parsed { + const valid = + Array.isArray(value) && + value.length <= MAX_SUGGESTIONS && + value.every((item) => typeof item === 'string' && item.trim().length > 0 && item.length <= MAX_SUGGESTION_LENGTH); + return valid + ? ok(value.map((item: string) => item.trim())) + : err([ + `'${path}' must be a list of up to ${MAX_SUGGESTIONS} questions, each a non-empty string of at most ${MAX_SUGGESTION_LENGTH} characters.` + ]); +} diff --git a/src/types/portal/config/service-address.ts b/src/types/portal/config/service-address.ts new file mode 100644 index 000000000..542e8cfaf --- /dev/null +++ b/src/types/portal/config/service-address.ts @@ -0,0 +1,54 @@ +import { err, ok } from 'neverthrow'; +import { isWebAddress, Parsed } from './fields.js'; + +const APIMATIC_DOMAIN = 'apimatic.io'; + +// Only names that never leave the machine: a `*.localhost` name is resolved through DNS on some systems, Windows' Node among them. +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** APIMatic's own Ask AI service, or one on this machine, since `sync` sends the APIMatic login there. */ +export class ServiceAddress { + private constructor(private readonly origin: string) {} + + public static parse(value: unknown, path: string): Parsed { + const text = typeof value === 'string' ? value : ''; + const url = isWebAddress(text) ? new URL(text) : null; + if (url === null || !(isApimatic(url) || isLoopback(url))) { + return err([ + `'${path}' must be the address of APIMatic's Ask AI service, starting with 'https://' and ending in ` + + `'${APIMATIC_DOMAIN}', or of one on this machine, such as 'http://localhost:8787'.` + ]); + } + // As written, so the schema can say the same: the URL parser would rewrite `127.1`, `%2e` or an empty port. + if (writtenOrigin(text) !== url.origin) { + return err([ + `'${path}' must be the service's address alone, written in full, without a path, query or ` + + `credentials, for example 'https://ask.apimatic.io'.` + ]); + } + return ok(new ServiceAddress(url.origin)); + } + + /** The service's origin, such as `https://ask.apimatic.io`. */ + public toString(): string { + return this.origin; + } +} + +function isApimatic(url: URL): boolean { + const labels = url.hostname.split('.'); + return url.protocol === 'https:' && labels.slice(-2).join('.') === APIMATIC_DOMAIN && !labels.includes(''); +} + +/** The text as an origin reads: in lower case, without one trailing slash or the scheme's default port. */ +function writtenOrigin(text: string): string { + return text + .toLowerCase() + .replace(/\/$/, '') + .replace(/^(https:\/\/[^/]+):443$/, '$1') + .replace(/^(http:\/\/[^/]+):80$/, '$1'); +} + +function isLoopback(url: URL): boolean { + return LOOPBACK_HOSTS.has(url.hostname); +} diff --git a/src/types/portal/copilot/copilot-bundle.ts b/src/types/portal/copilot/copilot-bundle.ts new file mode 100644 index 000000000..2c0365089 --- /dev/null +++ b/src/types/portal/copilot/copilot-bundle.ts @@ -0,0 +1,105 @@ +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { gzipSync } from 'node:zlib'; +import { isJsonObject } from '../../../utils/json-utils.js'; +import { SiteAddress } from '../config/site-address.js'; + +/** A page as Ask AI reads it: its portal-relative path, as the chat links it, and its Markdown. */ +export interface BundlePage { + path: string; + title: string; + description: string | null; + markdown: string; +} + +interface BundleDocument { + formatVersion: 1; + generator: { name: string; version: string }; + generatedAt: string; + siteUrl: string | null; + basePath: string; + pages: { + path: string; + title: string; + description?: string; + markdown: string; + }[]; +} + +const GENERATOR_NAME = 'apimatic-cli'; + +/** The portal's pages as the Ask AI service ingests them, in its format version 1. */ +export class CopilotBundle { + private constructor( + private readonly document: BundleDocument, + private readonly json: string = JSON.stringify(document) + ) {} + + /** A bundle `generate` wrote, kept as its exact bytes; null for one this CLI cannot read. */ + public static parse(json: string): CopilotBundle | null { + let document: unknown; + try { + document = JSON.parse(json); + } catch { + return null; + } + return isBundleDocument(document) ? new CopilotBundle(document, json) : null; + } + + public static of(pages: BundlePage[], site: SiteAddress | null, cliVersion: string, at: Date): CopilotBundle { + return new CopilotBundle({ + formatVersion: 1, + generator: { name: GENERATOR_NAME, version: cliVersion }, + generatedAt: at.toISOString(), + siteUrl: site === null ? null : site.toString(), + basePath: site?.path() ?? '/', + pages: pages.map(({ path, title, description, markdown }) => ({ + path, + title, + ...(description === null ? {} : { description }), + markdown + })) + }); + } + + public pageCount(): number { + return this.document.pages.length; + } + + public gzipped(): Buffer { + return gzipSync(this.json); + } + + /** As the deployed portal serves it for the Ask AI service to fetch: plain, since hosts compress it themselves. */ + public toJson(): string { + return this.json; + } + + /** Names these exact bytes: the service checks a deployed bundle.json against it, so it never indexes half a deploy. */ + public buildManifest(): string { + return JSON.stringify({ formatVersion: 1, buildId: createHash('sha256').update(this.json).digest('hex') }); + } +} + +/** A page as the portal's build writes it for Ask AI; null for anything else. */ +export function bundlePageOf(value: unknown): BundlePage | null { + if (!isJsonObject(value)) return null; + const { path, title, description, markdown } = value; + if (typeof path !== 'string' || typeof title !== 'string' || typeof markdown !== 'string') return null; + if (description !== null && typeof description !== 'string') return null; + return { path, title, description, markdown }; +} + +function isBundleDocument(value: unknown): value is BundleDocument { + return ( + isJsonObject(value) && + value.formatVersion === 1 && + isJsonObject(value.generator) && + typeof value.generatedAt === 'string' && + (value.siteUrl === null || typeof value.siteUrl === 'string') && + typeof value.basePath === 'string' && + Array.isArray(value.pages) && + // A page without a description leaves the key out. + value.pages.every((page) => isJsonObject(page) && bundlePageOf({ description: null, ...page }) !== null) + ); +} diff --git a/src/types/portal/copilot/generated-portal-context.ts b/src/types/portal/copilot/generated-portal-context.ts new file mode 100644 index 000000000..e13513e98 --- /dev/null +++ b/src/types/portal/copilot/generated-portal-context.ts @@ -0,0 +1,111 @@ +import { err, ok, Result } from 'neverthrow'; +import { FileService } from '../../../infrastructure/file-service.js'; +import { ZipService } from '../../../infrastructure/zip-service.js'; +import { DirectoryPath } from '../../file/directoryPath.js'; +import { FileName } from '../../file/fileName.js'; +import { FilePath } from '../../file/filePath.js'; +import { ZIP_FILE_NAME } from '../../portal-context.js'; +import { BundlePage, bundlePageOf, CopilotBundle } from './copilot-bundle.js'; +import { errorMessage } from '../../../utils/error-utils.js'; + +/** Where the deployed portal serves its bundle, under the site's address, for the Ask AI service to fetch. */ +export const COPILOT_DIRECTORY_NAME = '__copilot'; +const BUNDLE_FILE_NAME = 'bundle.json'; + +// Under `/api/`, where the portal's own routes are files with extensions and no page can collide with them. +const PAGES_DIRECTORY_NAME = 'api'; +const PAGES_FILE_NAME = 'copilot-pages.json'; + +/** Where a build of a portal with Ask AI writes its pages, for `generate` to bundle. Never deployed. */ +export const COPILOT_PAGES_FILE = `${PAGES_DIRECTORY_NAME}/${PAGES_FILE_NAME}`; + +/** A portal `portal generate` built or wrote, for the pages Ask AI answers from. */ +export class GeneratedPortalContext { + private readonly fileService = new FileService(); + private readonly zipService = new ZipService(); + + constructor(private readonly portalDirectory: DirectoryPath) {} + + private get pagesFile(): FilePath { + return new FilePath(this.portalDirectory.join(PAGES_DIRECTORY_NAME), new FileName(PAGES_FILE_NAME)); + } + + private get directory(): DirectoryPath { + return this.portalDirectory.join(COPILOT_DIRECTORY_NAME); + } + + private get bundleFile(): FilePath { + return new FilePath(this.directory, new FileName(BUNDLE_FILE_NAME)); + } + + /** The pages a build wrote for Ask AI, their file removed however it reads, so it is never deployed. */ + public async takePages(): Promise> { + const file = this.pagesFile; + try { + if (!(await this.fileService.fileExists(file))) { + return ok(null); + } + try { + const pages = pagesOf(await this.fileService.getContents(file)); + return pages === null ? err('the pages the build wrote could not be read') : ok(pages); + } finally { + await this.fileService.deleteFile(file); + } + } catch (error) { + return err(errorMessage(error)); + } + } + + /** Writes the bundle into the portal, so it is deployed with the pages it holds. */ + public async publish(bundle: CopilotBundle): Promise> { + try { + await this.fileService.createDirectoryIfNotExists(this.directory); + await this.fileService.writeContents(this.bundleFile, bundle.toJson()); + await this.fileService.writeContents( + new FilePath(this.directory, new FileName('build.json')), + bundle.buildManifest() + ); + return ok(undefined); + } catch (error) { + return err(errorMessage(error)); + } + } + + /** The bundle `generate` published, from the portal's directory or its `--zip` archive; null for none. */ + public async bundle(): Promise> { + const json = await this.bundleJson(); + if (json.isErr() || json.value === null) { + return json.map(() => null); + } + const bundle = CopilotBundle.parse(json.value); + return bundle === null ? err('its Ask AI pages could not be read') : ok(bundle); + } + + private async bundleJson(): Promise> { + try { + if (await this.fileService.fileExists(this.bundleFile)) { + return ok(await this.fileService.getContents(this.bundleFile)); + } + const archive = new FilePath(this.portalDirectory, new FileName(ZIP_FILE_NAME)); + if (!(await this.fileService.fileExists(archive))) { + return ok(null); + } + // Archived from the built directory, so the bundle is at the same path inside. + return this.zipService.readEntry(archive, `${COPILOT_DIRECTORY_NAME}/${BUNDLE_FILE_NAME}`); + } catch (error) { + return err(errorMessage(error)); + } + } +} + +function pagesOf(contents: string): BundlePage[] | null { + let value: unknown; + try { + value = JSON.parse(contents); + } catch { + return null; + } + if (!Array.isArray(value)) return null; + const pages = value.map(bundlePageOf); + return pages.every((page) => page !== null) ? pages : null; +} diff --git a/src/types/portal/copilot/ingest.ts b/src/types/portal/copilot/ingest.ts new file mode 100644 index 000000000..edd825b08 --- /dev/null +++ b/src/types/portal/copilot/ingest.ts @@ -0,0 +1,21 @@ +/** What the Ask AI service answers an upload with: `unchanged` when the pages match the version it already serves. */ +export interface IngestReceipt { + versionId: string; + status: 'queued' | 'unchanged'; + pageCount: number; +} + +export type IndexingState = 'queued' | 'building' | 'active' | 'superseded' | 'failed'; + +export interface IngestStatus { + versionId: string; + status: IndexingState; + pageCount: number; + changedPages: number; + removedPages: number; + chunkCount: number; + error: string | null; +} + +/** How an upload's indexing ended: serving its pages, or replaced by a newer upload before it could. */ +export type IndexedVersion = IngestStatus & { status: 'active' | 'superseded' }; diff --git a/src/types/portal/portal-config.ts b/src/types/portal/portal-config.ts index 56cf2ce4b..be73a9ab3 100644 --- a/src/types/portal/portal-config.ts +++ b/src/types/portal/portal-config.ts @@ -2,6 +2,7 @@ import { err, ok, Result } from 'neverthrow'; import { isJsonObject } from '../../utils/json-utils.js'; import { UrlPath } from '../file/urlPath.js'; import { AiConfig } from './config/ai-config.js'; +import { CopilotService, PortalCopilot } from './config/copilot-config.js'; import { BrandConfig, ColorMode } from './config/brand-config.js'; import { allOf, optional, Parsed, unknownKeys } from './config/fields.js'; import { Link } from './config/link.js'; @@ -35,6 +36,8 @@ export interface PortalIdentity { links: PortalLink[]; /** Whether each page offers to open itself in an external AI assistant. */ pageActions: boolean; + /** The Ask AI chat, or null when the portal offers none. */ + copilot: PortalCopilot | null; } const BLOCK = 'portal'; @@ -105,6 +108,11 @@ export class PortalConfig { return this.pluginAddress; } + /** The service the Ask AI chat is on, or null when the portal offers none. */ + public copilotService(): CopilotService | null { + return this.ai.copilotService(); + } + public brandSettings(): BrandConfig { return this.brand; } @@ -125,7 +133,8 @@ export class PortalConfig { favicon: favicon === null ? null : { url: favicon.portalPath(), type: favicon.imageType() }, colorMode: this.brand.mode(), links: this.navigation.headerLinks().map(portalLink), - pageActions: this.ai.offersPageActions() + pageActions: this.ai.offersPageActions(), + copilot: this.ai.copilot() }; } diff --git a/src/types/portal/portal-copilot-entry.ts b/src/types/portal/portal-copilot-entry.ts new file mode 100644 index 000000000..179d1e6e3 --- /dev/null +++ b/src/types/portal/portal-copilot-entry.ts @@ -0,0 +1,31 @@ +import { PortalConfig } from './portal-config.js'; + +const HEADER = "// Written by the APIMatic CLI from the 'portal' block of src/apimatic.json. Edit that instead."; + +/** The layout's `copilot-entry.ts`: the chat's `import()` only where offered, as a build chunks every one it finds. */ +export class PortalCopilotEntry { + private constructor(private readonly chat: boolean) {} + + public static of(config: PortalConfig): PortalCopilotEntry { + return new PortalCopilotEntry(config.copilotService() !== null); + } + + public toString(): string { + const body = this.chat + ? [ + "// Asked for as the page's own scripts run, so it loads alongside them.", + "import { lazy } from 'react';", + '', + "const chat = import('@/components/copilot');", + '', + 'export const CopilotChat = lazy(() => chat);' + ] + : [ + '// This portal offers no Ask AI chat, so nothing here imports it, and the build leaves it out.', + "import type CopilotChatComponent from '@/components/copilot';", + '', + 'export const CopilotChat: typeof CopilotChatComponent | null = null;' + ]; + return [HEADER, ...body].join('\n').concat('\n'); + } +} diff --git a/src/types/portal/portal-stylesheet.ts b/src/types/portal/portal-stylesheet.ts index 8f87a9a2f..cbc8d834e 100644 --- a/src/types/portal/portal-stylesheet.ts +++ b/src/types/portal/portal-stylesheet.ts @@ -3,6 +3,12 @@ import { PortalConfig } from './portal-config.js'; const HEADER = "/* Written by the APIMatic CLI from the 'portal' block of src/apimatic.json. Edit that instead. */"; +/** + * The Ask AI chat's preset: a `@source` of the chat's code, so Tailwind writes the classes it uses. Imported + * only for a portal that offers the chat, so no other portal's stylesheet carries them. + */ +export const CHAT_PRESET = "@import '@fumadocs/ai-chat/css/preset.css';"; + /** * Scoped with `:not(.dark)`: a bare `:root` has the specificity of the theme's `.dark` block and * comes after it, so a colour meant for light mode would win in dark mode too. @@ -16,10 +22,10 @@ export const DARK_SELECTOR = '.dark'; * can rewrite it whole when the block changes; rendered here so the CSS is tested without a build. */ export class PortalStylesheet { - private constructor(private readonly primary: { light: Color; dark: Color } | null) {} + private constructor(private readonly primary: { light: Color; dark: Color } | null, private readonly chat: boolean) {} public static of(config: PortalConfig): PortalStylesheet { - return new PortalStylesheet(config.brandSettings().brandColors().primaryColors()); + return new PortalStylesheet(config.brandSettings().brandColors().primaryColors(), config.copilotService() !== null); } public toString(): string { @@ -30,7 +36,8 @@ export class PortalStylesheet { PortalStylesheet.rule(LIGHT_SELECTOR, this.primary.light), PortalStylesheet.rule(DARK_SELECTOR, this.primary.dark) ]; - return [HEADER, ...rules].join('\n\n').concat('\n'); + // An import goes before any rule, or the stylesheet drops it. + return [HEADER, ...(this.chat ? [CHAT_PRESET] : []), ...rules].join('\n\n').concat('\n'); } private static rule(selector: string, primary: Color): string { diff --git a/test/actions/portal/copilot/sync.test.ts b/test/actions/portal/copilot/sync.test.ts new file mode 100644 index 000000000..0cabbbd1d --- /dev/null +++ b/test/actions/portal/copilot/sync.test.ts @@ -0,0 +1,275 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { gunzipSync } from 'node:zlib'; +import { err, ok } from 'neverthrow'; +import { PortalCopilotSyncAction } from '../../../../src/actions/portal/copilot/sync'; +import { CopilotIngestService, CopilotTarget } from '../../../../src/infrastructure/services/copilot-ingest-service'; +import { ServiceError, ServiceErrorCode } from '../../../../src/infrastructure/service-error'; +import { PortalCopilotSyncPrompts } from '../../../../src/prompts/portal/copilot/sync'; +import { CommandMetadata } from '../../../../src/types/common/command-metadata'; +import { DirectoryPath } from '../../../../src/types/file/directoryPath'; +import { FileName } from '../../../../src/types/file/fileName'; +import { FilePath } from '../../../../src/types/file/filePath'; +import { ZipService } from '../../../../src/infrastructure/zip-service'; +import { CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; +import { ProjectContext } from '../../../../src/types/project-context'; +import { SiteAddress } from '../../../../src/types/portal/config/site-address'; +import { GeneratedPortalContext } from '../../../../src/types/portal/copilot/generated-portal-context'; + +/** The bundle as the service receives it. */ +const uploaded = (bundle: CopilotBundle) => + JSON.parse(gunzipSync(bundle.gzipped()).toString('utf8')) as { + basePath: string; + siteUrl: string | null; + pages: { path: string }[]; + }; + +const COMMAND_METADATA: CommandMetadata = { commandName: 'portal:copilot:sync', shell: 'test' }; +const COPILOT = { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' }; + +/** The address of the service the portal's settings name. */ +const theService = sinon.match((address: unknown) => `${address as string}` === COPILOT.apiUrl, COPILOT.apiUrl); + +const indexed = (status: 'active' | 'superseded') => ({ + versionId: 'v-1', + status, + pageCount: 2, + changedPages: 1, + removedPages: 0, + chunkCount: 5, + error: null +}); + +describe('PortalCopilotSyncAction', () => { + let root: string; + let prompts: sinon.SinonStubbedInstance; + let upload: sinon.SinonStub; + let awaitIndexing: sinon.SinonStub; + + const write = (relative: string, contents: string) => { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + }; + + const writeConfig = (ai: unknown, url = 'https://example.com/docs') => + write( + 'src/apimatic.json', + JSON.stringify({ + portal: { site: { name: 'Acme', url }, ...(ai ? { ai } : {}) }, + languages: { typescript: {} } + }) + ); + + const writeSavedLogin = (authKey: string) => write('config.json', JSON.stringify({ email: 'a@b.c', authKey })); + + /** As `generate` writes it into a portal built for `https://example.com/docs`. */ + const PUBLISHED = CopilotBundle.of( + [ + { path: '/', title: 'Docs', description: null, markdown: '# Docs' }, + { path: '/guides/intro', title: 'Intro', description: 'Start', markdown: '# Intro' } + ], + SiteAddress.parse('https://example.com/docs', 'url')._unsafeUnwrap(), + '9.9.9', + new Date('2026-10-14T12:00:00.000Z') + ); + + const writePortal = () => write('portal/__copilot/bundle.json', PUBLISHED.toJson()); + + const execute = (wait = true, authKey: string | null = 'auth-key') => { + const project = ProjectContext.in(new DirectoryPath(root)); + return new PortalCopilotSyncAction(new DirectoryPath(root), COMMAND_METADATA, authKey).execute( + project, + project.portalDirectory(), + wait + ); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-sync-')); + prompts = sinon.stub(PortalCopilotSyncPrompts.prototype); + // The spinners would render to stdout; pass the underlying promise straight through. + prompts.upload.callsFake((fn) => fn); + prompts.awaitIndexing.callsFake((fn) => fn); + upload = sinon + .stub(CopilotIngestService.prototype, 'upload') + .resolves(ok({ versionId: 'v-1', status: 'queued', pageCount: 2 })); + awaitIndexing = sinon.stub(CopilotIngestService.prototype, 'awaitIndexing').resolves(ok(indexed('active'))); + writeConfig({ copilot: COPILOT }); + writePortal(); + }); + + afterEach(() => { + sinon.restore(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('uploads the bundle generate wrote, as it is, and waits until Ask AI answers from it', async () => { + // The address has changed since the portal was generated: the bundle keeps the one it was built for. + writeConfig({ copilot: COPILOT }, 'https://example.com/api'); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + const [copilot, bundle, , authKey] = upload.firstCall.args as [CopilotTarget, CopilotBundle, unknown, string]; + expect(copilot.key).to.equal(COPILOT.key); + expect(`${copilot.address}`).to.equal(COPILOT.apiUrl); + expect(authKey).to.equal('auth-key'); + expect(bundle.toJson()).to.equal(PUBLISHED.toJson()); + expect(uploaded(bundle)).to.deep.include({ basePath: '/docs/', siteUrl: 'https://example.com/docs' }); + expect(awaitIndexing.firstCall.args[1]).to.equal('v-1'); + expect(prompts.indexed.calledOnceWith(indexed('active'))).to.be.true; + }); + + it('uploads the bundle of a portal generated with --zip, which holds only the archive', async () => { + const portal = path.join(root, 'portal'); + const archive = new FilePath(new DirectoryPath(root), new FileName('portal.zip')); + (await new ZipService().archive(new DirectoryPath(portal), archive))._unsafeUnwrap(); + fs.rmSync(portal, { recursive: true }); + fs.mkdirSync(portal); + fs.renameSync(archive.toString(), path.join(portal, 'portal.zip')); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect((upload.firstCall.args[1] as CopilotBundle).toJson()).to.equal(PUBLISHED.toJson()); + }); + + it('reports a portal Ask AI already answers from, without waiting', async () => { + upload.resolves(ok({ versionId: 'v-0', status: 'unchanged', pageCount: 2 })); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect(prompts.alreadyUpToDate.calledOnceWith(2)).to.be.true; + expect(awaitIndexing.called).to.be.false; + }); + + it('returns once uploaded with --no-wait', async () => { + const result = await execute(false); + + expect(result.isSuccess()).to.be.true; + expect(prompts.indexingQueued.calledOnce).to.be.true; + expect(awaitIndexing.called).to.be.false; + }); + + it('succeeds, saying so, when a newer sync superseded this one', async () => { + awaitIndexing.resolves(ok(indexed('superseded'))); + + const result = await execute(); + + expect(result.isSuccess()).to.be.true; + expect(prompts.superseded.calledOnce).to.be.true; + }); + + it("fails with the service's refusal, naming the service", async () => { + const forbidden = ServiceError.forbidden('This key cannot sync this portal.'); + upload.resolves(err(forbidden)); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.uploadFailed.calledOnceWith(forbidden, theService)).to.be.true; + }); + + it('fails when the service cannot be reached', async () => { + upload.resolves(err(ServiceError.NetworkError)); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.uploadFailed.firstCall.args[0].code).to.equal(ServiceErrorCode.NetworkError); + }); + + it('fails when indexing fails', async () => { + const failed = ServiceError.processingFailed('Embedding provider unavailable.'); + awaitIndexing.resolves(err(failed)); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.indexingFailed.calledOnceWith(failed, theService)).to.be.true; + }); + + it('refuses, without uploading, a service APIMatic does not run', async () => { + writeSavedLogin('saved-key'); + writeConfig({ copilot: { ...COPILOT, apiUrl: 'https://ask.example.com' } }); + + const result = await execute(true, null); + + expect(result.isFailed()).to.be.true; + expect(prompts.sourceProblem.calledOnce).to.be.true; + expect(upload.called).to.be.false; + }); + + it('uses the passed key, else the saved login, a blank --auth-key counting as none', async () => { + writeSavedLogin('saved-key'); + + for (const apiUrl of ['https://ask.apimatic.io', 'http://localhost:8787']) { + writeConfig({ copilot: { ...COPILOT, apiUrl } }); + + for (const passed of ['passed-key', null, '', ' ']) { + expect((await execute(true, passed)).isSuccess(), `${apiUrl} ${JSON.stringify(passed)}`).to.be.true; + } + } + const sent = ['passed-key', 'saved-key', 'saved-key', 'saved-key']; + expect(upload.args.map((args) => args[3])).to.deep.equal([...sent, ...sent]); + expect(awaitIndexing.args.map((args) => args[3])).to.deep.equal([...sent, ...sent]); + }); + + it('asks the user to log in, without uploading, when no key is passed or saved', async () => { + writeConfig({ copilot: { ...COPILOT, apiUrl: 'https://ask.apimatic.io' } }); + // `auth logout` leaves the file with a blank key. + writeSavedLogin(''); + + const result = await execute(true, null); + + expect(result.isFailed()).to.be.true; + expect(prompts.notLoggedIn.calledOnce).to.be.true; + expect(upload.called).to.be.false; + }); + + it('fails without uploading when the portal has no Ask AI chat', async () => { + writeConfig(null); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.copilotNotConfigured.calledOnce).to.be.true; + expect(upload.called).to.be.false; + }); + + it('fails without uploading when apimatic.json is missing', async () => { + fs.rmSync(path.join(root, 'src', 'apimatic.json')); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.sourceProblem.firstCall.args[0]).to.deep.equal({ kind: 'missingConfig' }); + expect(upload.called).to.be.false; + }); + + it('fails without uploading when the portal cannot be read', async () => { + sinon.stub(GeneratedPortalContext.prototype, 'bundle').resolves(err('EACCES: permission denied')); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.portalNotRead.calledOnceWith(sinon.match.any, 'EACCES: permission denied')).to.be.true; + expect(upload.called).to.be.false; + }); + + it('fails without uploading when the portal has not been generated, or was without Ask AI', async () => { + fs.rmSync(path.join(root, 'portal', '__copilot'), { recursive: true }); + write('portal/index.html', ''); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + expect(prompts.portalNotGenerated.calledOnce).to.be.true; + expect(upload.called).to.be.false; + }); +}); diff --git a/test/actions/portal/generate.test.ts b/test/actions/portal/generate.test.ts index c1e5f15d3..87fefe90c 100644 --- a/test/actions/portal/generate.test.ts +++ b/test/actions/portal/generate.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -15,12 +16,14 @@ import { ServiceError } from '../../../src/infrastructure/service-error'; import { DirectoryPath } from '../../../src/types/file/directoryPath'; import { CommandMetadata } from '../../../src/types/common/command-metadata'; import { ProjectContext } from '../../../src/types/project-context'; +import { COPILOT_PAGES_FILE, GeneratedPortalContext } from '../../../src/types/portal/copilot/generated-portal-context'; import { completeArtifacts, stubPreparePortalProject } from './prepare-project-stubs'; const COMMAND_METADATA: CommandMetadata = { commandName: 'portal generate', shell: 'test' }; const FIXTURE = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/default'); const CODE_SAMPLES_FIXTURE = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/code-samples'); const SUBPATH_FIXTURE = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/subpath'); +const COPILOT_FIXTURE = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/copilot'); /** The catalogs the merged fixture expects, read the way the service reads them. */ const samplesFromFixture = (): CodeSampleCatalogs => { @@ -37,6 +40,7 @@ describe('GenerateAction', () => { let shared: ReturnType; let authorize: sinon.SinonStub; let build: sinon.SinonStub; + let builtSite: DirectoryPath; const execute = (projectDirectory = FIXTURE, force = false, zip = false) => new GenerateAction(new DirectoryPath(root), COMMAND_METADATA, 'auth-key').execute( @@ -58,7 +62,7 @@ describe('GenerateAction', () => { portalDirectory = new DirectoryPath(root).join('portal'); // What a successful build hands back: a directory holding the finished site. - const builtSite = new DirectoryPath(root).join('site'); + builtSite = new DirectoryPath(root).join('site'); fs.mkdirSync(builtSite.toString(), { recursive: true }); fs.writeFileSync(path.join(builtSite.toString(), 'index.html'), ''); fs.writeFileSync(path.join(builtSite.toString(), '_shell.html'), ''); @@ -239,6 +243,86 @@ describe('GenerateAction', () => { expect(inPortal('404.html')).to.be.true; expect(prompts.portalGenerated.calledOnceWith(portalDirectory)).to.be.true; expect(prompts.nextSteps.calledOnceWith(portalDirectory, false)).to.be.true; + expect(prompts.copilotUpdatesAfterDeploy.called, 'no Ask AI to update').to.be.false; + expect(inPortal('__copilot')).to.be.false; + }); + + describe('with Ask AI', () => { + const readPortal = (relative: string) => fs.readFileSync(path.join(portalDirectory.toString(), relative), 'utf8'); + + const PAGE = { path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }; + + /** As a build of a portal with Ask AI writes them. */ + const writePages = (pages: unknown[] = [PAGE]) => { + const file = path.join(builtSite.toString(), COPILOT_PAGES_FILE); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(pages)); + }; + + it('deploys the pages with the portal, named by a build ID the service compares', async () => { + writePages(); + + const result = await execute(COPILOT_FIXTURE); + + expect(result.isSuccess()).to.be.true; + expect(inPortal(COPILOT_PAGES_FILE), 'the pages as the build wrote them').to.be.false; + const bundle = readPortal('__copilot/bundle.json'); + const { pages } = JSON.parse(bundle) as { pages: unknown[] }; + expect(pages).to.deep.equal([PAGE]); + const manifest = JSON.parse(readPortal('__copilot/build.json')) as { formatVersion: number; buildId: string }; + expect(manifest.formatVersion).to.equal(1); + // The service refuses a deployed bundle.json that does not hash to this. + expect(manifest.buildId).to.equal(createHash('sha256').update(bundle).digest('hex')); + // The fixture gives no `site.url`, so the service cannot find the portal by itself. + expect(prompts.copilotUpdatesAfterDeploy.calledOnceWith(null)).to.be.true; + }); + + it('puts the pages in the archive when the portal is zipped', async () => { + writePages(); + + const result = await execute(COPILOT_FIXTURE, false, true); + + expect(result.isSuccess()).to.be.true; + expect(inPortal('__copilot')).to.be.false; + expect(fs.existsSync(path.join(builtSite.toString(), '__copilot', 'build.json'))).to.be.true; + }); + + it('still saves the portal when its pages cannot be read', async () => { + writePages(); + sinon.stub(GeneratedPortalContext.prototype, 'takePages').resolves(err('EACCES: permission denied')); + + const result = await execute(COPILOT_FIXTURE); + + expect(result.isSuccess()).to.be.true; + expect(inPortal('index.html')).to.be.true; + expect(inPortal('__copilot')).to.be.false; + expect(prompts.copilotBundleNotWritten.calledOnceWith('EACCES: permission denied')).to.be.true; + expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; + }); + + it('still saves the portal when it has no pages', async () => { + writePages([]); + + const result = await execute(COPILOT_FIXTURE); + + expect(result.isSuccess()).to.be.true; + expect(inPortal('index.html')).to.be.true; + expect(inPortal('__copilot')).to.be.false; + expect(prompts.copilotPagesNotFound.calledOnce).to.be.true; + expect(prompts.copilotPagesNotBuilt.called).to.be.false; + expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; + }); + + it('still saves the portal when the build wrote no pages for Ask AI, naming it a fault of the build', async () => { + const result = await execute(COPILOT_FIXTURE); + + expect(result.isSuccess()).to.be.true; + expect(inPortal('index.html')).to.be.true; + expect(inPortal('__copilot')).to.be.false; + expect(prompts.copilotPagesNotBuilt.calledOnce).to.be.true; + expect(prompts.copilotPagesNotFound.called).to.be.false; + expect(prompts.copilotUpdatesAfterDeploy.called).to.be.false; + }); }); it('writes an archive instead when asked', async () => { diff --git a/test/commands/portal/copilot/sync-flags.test.ts b/test/commands/portal/copilot/sync-flags.test.ts new file mode 100644 index 000000000..c919d7db8 --- /dev/null +++ b/test/commands/portal/copilot/sync-flags.test.ts @@ -0,0 +1,18 @@ +import { expect } from 'chai'; +import PortalCopilotSync from '../../../../src/commands/portal/copilot/sync.js'; +import { flagParser } from '../../flag-parser.js'; + +const { parse } = flagParser(PortalCopilotSync); + +describe('portal copilot sync flags', () => { + it('waits for indexing unless told not to', async () => { + expect((await parse([])).flags.wait).to.be.true; + expect((await parse(['--no-wait'])).flags.wait).to.be.false; + }); + + it('takes the project, the portal and the auth key by their short names', async () => { + const { flags } = await parse(['-i', './project', '-d', './site', '-k', 'key']); + + expect(flags).to.include({ input: './project', destination: './site', 'auth-key': 'key' }); + }); +}); diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index 9e6fc12a6..6cafc39cb 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -12,6 +12,8 @@ import { DirectoryPath } from '../../src/types/file/directoryPath'; import { FilePath } from '../../src/types/file/filePath'; import { CodeSampleCatalog, CodeSampleCatalogs } from '../../src/types/portal/code-samples'; import { PortalArtifacts } from '../../src/types/portal/portal-artifacts'; +import { BundlePage } from '../../src/types/portal/copilot/copilot-bundle'; +import { COPILOT_PAGES_FILE } from '../../src/types/portal/copilot/generated-portal-context'; import { Language } from '../../src/types/sdk/generate'; import { canonical } from '../../src/infrastructure/tmp-extensions'; @@ -160,6 +162,34 @@ async function typeCheck(project: DirectoryPath) { ); } +/** Published files naming one of these build-machine directories, in any spelling. */ +function publishedFilesNaming(output: DirectoryPath, ...directories: string[]): string[] { + // Three spellings of each: a bundler normalises separators either way, and a Windows + // path embedded in a string literal has its backslashes escaped. + const secrets = directories.flatMap((absolute) => { + const native = absolute.replace(/\//g, '\\'); + return [native, native.replace(/\\/g, '\\\\'), absolute.replace(/\\/g, '/')]; + }); + + const walk = (directory: string): string[] => + fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const child = path.join(directory, entry.name); + return entry.isDirectory() ? walk(child) : [child]; + }); + + return ( + walk(output.toString()) + // Images and fonts cannot carry a path the build put there, and reading them as text + // only invites a false match. + .filter((file) => !/\.(png|jpe?g|gif|svg|ico|woff2?)$/i.test(file)) + .filter((file) => { + const content = fs.readFileSync(file, 'utf8'); + return secrets.some((secret) => content.includes(secret)); + }) + .map((file) => path.relative(output.toString(), file)) + ); +} + /** The browser's scripts, by name. */ const scriptsOf = (output: DirectoryPath) => fs @@ -167,6 +197,31 @@ const scriptsOf = (output: DirectoryPath) => .filter((name) => name.endsWith('.js')) .map((name) => ({ name, text: fs.readFileSync(path.join(output.toString(), 'assets', name), 'utf8') })); +/** Code only the Ask AI chat carries: the address it opens a session at. */ +const CHAT_CODE = '/v1/sessions'; + +/** A class only the Ask AI chat uses, on its source chips' numbers. */ +const CHAT_CLASS = '.text-\\[10\\.5px\\]'; + +/** + * The scripts a page loads before anything asks for more: those its HTML names, and every one + * they import in turn. A chunk reached only through `import()` is left out. + */ +function scriptsLoadedBy(output: DirectoryPath, page: string): string[] { + const assets = path.join(output.toString(), 'assets'); + const named = (text: string, pattern: RegExp) => + [...text.matchAll(pattern)].map((match) => path.basename(match[1]!)).filter((name) => name.endsWith('.js')); + const loaded = new Set(); + const pending = named(page, /(?:src|href)="[^"]*\/assets\/([^"]+)"/g); + while (pending.length > 0) { + const name = pending.pop()!; + if (loaded.has(name) || !fs.existsSync(path.join(assets, name))) continue; + loaded.add(name); + pending.push(...named(fs.readFileSync(path.join(assets, name), 'utf8'), /(?:from|import)\s*["']\.\/([^"']+)["']/g)); + } + return [...loaded]; +} + const stylesheetOf = (output: DirectoryPath) => { const assets = path.join(output.toString(), 'assets'); return fs @@ -249,33 +304,7 @@ const stylesheetOf = (output: DirectoryPath) => { expect(offenders).to.deep.equal([]); }); - /** Published files naming one of these build-machine directories, in any spelling. */ - const filesNaming = (...directories: string[]) => { - // Three spellings of each: a bundler normalises separators either way, and a Windows - // path embedded in a string literal has its backslashes escaped. - const secrets = directories.flatMap((absolute) => { - const native = absolute.replace(/\//g, '\\'); - return [native, native.replace(/\\/g, '\\\\'), absolute.replace(/\\/g, '/')]; - }); - - const walk = (directory: string): string[] => - fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const child = path.join(directory, entry.name); - return entry.isDirectory() ? walk(child) : [child]; - }); - - return ( - walk(portal.output().toString()) - // Images and fonts cannot carry a path the build put there, and reading them as text - // only invites a false match. - .filter((file) => !/\.(png|jpe?g|gif|svg|ico|woff2?)$/i.test(file)) - .filter((file) => { - const content = fs.readFileSync(file, 'utf8'); - return secrets.some((secret) => content.includes(secret)); - }) - .map((file) => path.relative(portal.output().toString(), file)) - ); - }; + const filesNaming = (...directories: string[]) => publishedFilesNaming(portal.output(), ...directories); // The `.server` split exists so the build machine's filesystem stays out of what is // published: `portal.server.ts` holds the absolute path of every specification, and the @@ -290,6 +319,14 @@ const stylesheetOf = (output: DirectoryPath) => { expect(filesNaming(fixture.join('content').toString(), fixture.toString())).to.deep.equal([]); }); + // A portal without the chat pays nothing for it, not even a chunk it never loads. + it('ships none of the Ask AI chat, neither its code nor its classes', () => { + const chat = scriptsOf(portal.output()).filter((script) => script.text.includes(CHAT_CODE)); + + expect(chat.map(({ name }) => name)).to.deep.equal([]); + expect(stylesheetOf(portal.output())).to.not.contain(CHAT_CLASS); + }); + it('writes the sidebar tree to one cache file instead of into every page payload', () => { expect(treeCacheFiles()).to.have.length(1); }); @@ -499,6 +536,15 @@ const stylesheetOf = (output: DirectoryPath) => { ).to.deep.equal([]); }); + it('loads no Ask AI chat code in a portal that offers no chat', () => { + const output = portal.output(); + const loaded = scriptsLoadedBy(output, read('index.html')); + + expect(loaded, 'the page names its scripts').to.not.be.empty; + expect(loaded.filter((name) => read('assets/' + name).includes(CHAT_CODE))).to.deep.equal([]); + expect(read('index.html')).to.not.contain('>Ask AI<'); + }); + it('bundles Geist and loads the neutral theme', () => { const css = stylesheetOf(portal.output()); @@ -983,3 +1029,85 @@ const stylesheetOf = (output: DirectoryPath) => { expect(read(`${LIST_ORDERS}.md`)).to.contain('grandTotalCents'); }); }); + +(enabled ? describe : describe.skip)('portal build, with the Ask AI chat (end to end)', function () { + this.timeout(10 * 60 * 1000); + + const portal = portalBuilt('copilot'); + const { read } = portal; + + /** The pages as the build wrote them for `generate`, read without taking them. */ + const builtPages = () => JSON.parse(read(COPILOT_PAGES_FILE)) as BundlePage[]; + + // The chat wraps the whole layout, so a prerender that did not wait for its chunk would leave the button out. + it('prerenders each page with the chat button', () => { + expect(read('index.html')).to.contain('>Ask AI<'); + }); + + it('keeps the chat in its own chunk, out of what a page loads before it asks for the chat', () => { + const output = portal.output(); + const chat = scriptsOf(output).filter((script) => script.text.includes(CHAT_CODE)); + const loaded = scriptsLoadedBy(output, read('index.html')); + + expect(chat, 'a chunk carries the chat').to.not.be.empty; + expect(loaded.filter((name) => chat.some((script) => script.name === name))).to.deep.equal([]); + }); + + // The layout takes the chat from a module the CLI writes per portal: this is the one build that has the chat's. + it('type-checks with the chat', async () => { + const result = await typeCheck(portal.project()); + + expect(result.exitCode, result.all).to.equal(0); + }); + + it("styles the chat, its classes in the portal's stylesheet", () => { + expect(stylesheetOf(portal.output())).to.contain(CHAT_CLASS); + }); + + // `generate` bundles these for the service: every page the build renders, those the navigation leaves + // out and those whose names are not ASCII too, and no more. + it('writes every page Ask AI answers from, and no more', () => { + const pages = builtPages(); + const twinOf = (page: string) => (page === '/' ? 'index.md' : `${decodeURIComponent(page.slice(1))}.md`); + const twins = fs + .readdirSync(portal.output().toString(), { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('.md')) + .map((file) => file.split(path.sep).join('/')); + + expect(pages.map(({ path: page }) => twinOf(page).normalize()).sort()).to.deep.equal( + twins.map((twin) => twin.normalize()).sort() + ); + expect(pages.find(({ title }) => title === 'Café')).to.deep.include({ + description: 'A page whose name is not ASCII.' + }); + expect(pages.map(({ title }) => title)).to.include('Unlisted'); + }); + + // As `llms-full.txt` renders them: each operation's slice of the specification would repeat the schemas it shares. + it('renders each page without its specification', () => { + const operations = builtPages().filter(({ path: page }) => page.startsWith('/api/')); + + expect(operations, 'the specification has operations').to.not.be.empty; + for (const { path: page, markdown } of operations) { + expect(markdown, page).to.contain(`(${page})`).and.not.contain('```yaml'); + } + }); + + it('tells the browser the public key, the service and the starter questions, and no more', () => { + const scripts = scriptsOf(portal.output()); + // The browser imports this file whole, so what it holds is what every visitor is told. + const { copilot } = JSON.parse( + fs.readFileSync(path.join(portal.project().toString(), 'portal.identity.json'), 'utf8') + ) as { copilot: unknown }; + + expect(copilot).to.deep.equal({ + key: 'pk_test_e2e', + apiUrl: 'https://ask.apimatic.io', + suggestions: ['How do I list pets?'] + }); + expect(scripts.some((script) => script.text.includes('pk_test_e2e'))).to.be.true; + expect(scripts.some((script) => script.text.includes('https://ask.apimatic.io'))).to.be.true; + const fixture = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/copilot/src'); + expect(publishedFilesNaming(portal.output(), portal.project().toString(), fixture.toString())).to.deep.equal([]); + }); +}); diff --git a/test/infrastructure/generation-status-poller.test.ts b/test/infrastructure/generation-status-poller.test.ts index b2e881dd6..5ddf2035d 100644 --- a/test/infrastructure/generation-status-poller.test.ts +++ b/test/infrastructure/generation-status-poller.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { ok, Result } from 'neverthrow'; -import { pollUntilCompleted } from '../../src/infrastructure/generation-status-poller'; +import { pollUntilCompleted, pollUntilSettled } from '../../src/infrastructure/generation-status-poller'; import { ServiceError, ServiceErrorCode } from '../../src/infrastructure/service-error'; // The service suites reach the timeout with millisecond budgets, which only ever renders the @@ -36,7 +36,9 @@ describe('pollUntilCompleted', () => { }; it('names the budget in minutes', async () => { - expect(await timeoutAfter(5 * 60_000, 'Plugin generation')).to.equal('Plugin generation timed out after 5 minutes.'); + expect(await timeoutAfter(5 * 60_000, 'Plugin generation')).to.equal( + 'Plugin generation timed out after 5 minutes.' + ); }); it('says minute rather than minutes for a one-minute budget', async () => { @@ -51,3 +53,20 @@ describe('pollUntilCompleted', () => { expect(await timeoutAfter(90_000, 'Portal generation')).to.equal('Portal generation timed out after 1 minute.'); }); }); + +describe('pollUntilSettled', () => { + it('polls while the status is unsettled, and ends with what settles it', async () => { + const statuses = ['queued', 'building', 'active']; + let polls = 0; + + const result = await pollUntilSettled({ + pollIntervalMs: 1, + fetchStatus: async (): Promise> => ok(statuses[polls++]), + timeout: { budgetMs: 5000, label: 'Indexing' }, + settle: (status) => (status === 'active' ? ok(`settled on ${status}`) : null) + }); + + expect(result._unsafeUnwrap()).to.equal('settled on active'); + expect(polls).to.equal(3); + }); +}); diff --git a/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index f6ace380a..4a93183b2 100644 --- a/test/infrastructure/portal-project-service.test.ts +++ b/test/infrastructure/portal-project-service.test.ts @@ -23,6 +23,7 @@ import { PortalArtifacts } from '../../src/types/portal/portal-artifacts'; import { PortalLanguages } from '../../src/types/portal/portal-languages'; import { PortalSource } from '../../src/types/portal/portal-source'; import { PortalStylesheet } from '../../src/types/portal/portal-stylesheet'; +import { PortalCopilotEntry } from '../../src/types/portal/portal-copilot-entry'; import { Language } from '../../src/types/sdk/generate'; const NO_ARTIFACTS = PortalArtifacts.none(); @@ -258,6 +259,22 @@ describe('PortalProjectService', () => { expect(fs.readFileSync(path.join(styles, 'app.css'), 'utf8')).to.contain("@import './theme.css';"); }); + // `portal serve` rewrites it as the block changes, and the dev server reloads the layout with it. + it('writes the module the layout takes the Ask AI chat from, and rewrites it as the block changes', async () => { + const entry = () => fs.readFileSync(path.join(project.toString(), 'src/lib/copilot-entry.ts'), 'utf8'); + const withoutChat = configFor({ site: { name: 'My API' } }); + const withChat = configFor({ + site: { name: 'My API' }, + ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' } } + }); + + (await service.prepare(project, sourceFor({ config: withoutChat }), NO_ARTIFACTS))._unsafeUnwrap(); + expect(entry()).to.equal(PortalCopilotEntry.of(withoutChat).toString()); + + expect((await service.applyConfig(project, settingsFor(withChat)))._unsafeUnwrap()).to.be.true; + expect(entry()).to.equal(PortalCopilotEntry.of(withChat).toString()); + }); + it('leaves no identity placeholder in the module the browser receives', async () => { (await service.prepare(project, sourceFor(), NO_ARTIFACTS))._unsafeUnwrap(); diff --git a/test/infrastructure/services/copilot-ingest-service.test.ts b/test/infrastructure/services/copilot-ingest-service.test.ts new file mode 100644 index 000000000..cf702528a --- /dev/null +++ b/test/infrastructure/services/copilot-ingest-service.test.ts @@ -0,0 +1,182 @@ +import net from 'node:net'; +import { Buffer } from 'node:buffer'; +import { gunzipSync } from 'node:zlib'; +import nock from 'nock'; +import { expect } from 'chai'; +import { CopilotIngestService } from '../../../src/infrastructure/services/copilot-ingest-service'; +import { ServiceErrorCode } from '../../../src/infrastructure/service-error'; +import { CopilotBundle } from '../../../src/types/portal/copilot/copilot-bundle'; +import { ServiceAddress } from '../../../src/types/portal/config/service-address'; + +const API_URL = 'http://localhost:8787'; +const at = (url: string) => ServiceAddress.parse(url, 'apiUrl')._unsafeUnwrap(); +const COPILOT = { key: 'pk_test', address: at(API_URL) }; +const VERSION_ID = 'v-1'; + +const bundle = CopilotBundle.of( + [{ path: '/', title: 'Home', description: null, markdown: '# Home' }], + null, + '1.0.0', + new Date('2026-10-14T00:00:00.000Z') +); + +const statusBody = (status: string, extra: Record = {}) => ({ + versionId: VERSION_ID, + status, + pageCount: 12, + changedPages: 3, + removedPages: 1, + chunkCount: 40, + error: null, + ...extra +}); + +/** A port nothing listens on, so a connection to it is refused. */ +const closedPort = async (): Promise => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as net.AddressInfo; + await new Promise((resolve) => server.close(() => resolve())); + return port; +}; + +describe('CopilotIngestService', () => { + const service = new CopilotIngestService({ pollIntervalMs: 1, indexTimeoutMs: 5000 }); + + const upload = () => service.upload(COPILOT, bundle, 'bash', 'auth-key'); + const awaitIndexing = () => service.awaitIndexing(COPILOT, VERSION_ID, 'bash', 'auth-key'); + + beforeEach(() => nock.cleanAll()); + + afterEach(() => nock.cleanAll()); + + describe('upload', () => { + it('posts the gzipped bundle with both keys and answers with the queued version', async () => { + let posted: unknown; + const scope = nock(API_URL, { + reqheaders: { + authorization: 'X-Auth-Key auth-key', + 'x-copilot-key': 'pk_test', + 'content-type': 'application/gzip' + } + }) + .post('/v1/ingest', (body: string) => { + posted = JSON.parse(gunzipSync(Buffer.from(body, 'hex')).toString('utf8')); + return true; + }) + .reply(202, { versionId: VERSION_ID, status: 'queued', pageCount: 1 }); + + const result = await upload(); + + expect(result._unsafeUnwrap()).to.deep.equal({ versionId: VERSION_ID, status: 'queued', pageCount: 1 }); + expect(posted).to.deep.equal(JSON.parse(gunzipSync(bundle.gzipped()).toString('utf8'))); + expect(scope.isDone()).to.be.true; + }); + + it("surfaces the service's message for a refused upload", async () => { + nock(API_URL) + .post('/v1/ingest') + .reply(403, { code: 'sync_forbidden', message: 'This key cannot sync this portal.' }); + + const error = (await upload())._unsafeUnwrapErr(); + + expect(error.code).to.equal(ServiceErrorCode.Forbidden); + expect(error.errorMessage).to.equal('This key cannot sync this portal.'); + }); + + it('surfaces the message of a bundle that is invalid or too large', async () => { + nock(API_URL).post('/v1/ingest').reply(413, { code: 'too_large', message: 'The bundle is over 50 MB.' }); + + expect((await upload())._unsafeUnwrapErr().errorMessage).to.equal('The bundle is over 50 MB.'); + }); + + it('reports a service that cannot be reached as a network error', async () => { + const unreachable = { ...COPILOT, address: at(`http://127.0.0.1:${await closedPort()}`) }; + + const result = await service.upload(unreachable, bundle, 'bash', 'auth-key'); + + expect(result._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.NetworkError); + }); + + it('reports an upload that runs out of time as a timeout, not as a service it cannot reach', async () => { + nock(API_URL).post('/v1/ingest').delay(500).reply(200, { versionId: VERSION_ID, status: 'queued', pageCount: 1 }); + const impatient = new CopilotIngestService({ uploadTimeoutMs: 50 }); + + const failure = (await impatient.upload(COPILOT, bundle, 'bash', 'auth-key'))._unsafeUnwrapErr(); + + expect(failure.code).to.equal(ServiceErrorCode.Timeout); + expect(failure.errorMessage).to.equal('The upload did not finish within 1 second.'); + }); + }); + + describe('awaitIndexing', () => { + it('polls until the version is active', async () => { + const scope = nock(API_URL, { reqheaders: { 'x-copilot-key': 'pk_test' } }) + .get(`/v1/ingest/${VERSION_ID}`) + .reply(200, statusBody('queued')) + .get(`/v1/ingest/${VERSION_ID}`) + .reply(200, statusBody('building')) + .get(`/v1/ingest/${VERSION_ID}`) + .reply(200, statusBody('active')); + + const result = await awaitIndexing(); + + expect(result._unsafeUnwrap()).to.deep.include({ status: 'active', pageCount: 12, changedPages: 3 }); + expect(scope.isDone()).to.be.true; + }); + + it('ends on a version a newer sync superseded', async () => { + nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).reply(200, statusBody('superseded')); + + expect((await awaitIndexing())._unsafeUnwrap().status).to.equal('superseded'); + }); + + it("fails as failed processing, carrying the service's reason, when indexing failed", async () => { + nock(API_URL) + .get(`/v1/ingest/${VERSION_ID}`) + .reply(200, statusBody('building')) + .get(`/v1/ingest/${VERSION_ID}`) + .reply(200, statusBody('failed', { error: 'Embedding provider unavailable.' })); + + const failure = (await awaitIndexing())._unsafeUnwrapErr(); + + expect(failure.code).to.equal(ServiceErrorCode.ProcessingFailed); + expect(failure.errorMessage).to.equal('Embedding provider unavailable.'); + }); + + it('fails as failed processing with no reason when the service gives none', async () => { + nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).reply(200, statusBody('failed')); + + const failure = (await awaitIndexing())._unsafeUnwrapErr(); + + expect(failure.code).to.equal(ServiceErrorCode.ProcessingFailed); + expect(failure.errorMessage).to.equal(''); + }); + + it('times out on a version that never finishes', async () => { + nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).times(1000).reply(200, statusBody('building')); + const impatient = new CopilotIngestService({ pollIntervalMs: 1, indexTimeoutMs: 20 }); + + const result = await impatient.awaitIndexing(COPILOT, VERSION_ID, 'bash', 'auth-key'); + + expect(result._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.Timeout); + expect(result._unsafeUnwrapErr().errorMessage).to.equal('Indexing timed out.'); + }); + + it('refuses a status body it cannot read', async () => { + nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).reply(200, { status: 'done' }); + + expect((await awaitIndexing())._unsafeUnwrapErr().code).to.equal(ServiceErrorCode.InvalidResponse); + }); + + it('reports a status check that runs out of time as a timeout, not as a service it cannot reach', async () => { + nock(API_URL).get(`/v1/ingest/${VERSION_ID}`).delay(500).reply(200, statusBody('building')); + const impatient = new CopilotIngestService({ pollIntervalMs: 1, statusTimeoutMs: 50 }); + + const failure = (await impatient.awaitIndexing(COPILOT, VERSION_ID, 'bash', 'auth-key'))._unsafeUnwrapErr(); + + expect(failure.code).to.equal(ServiceErrorCode.Timeout); + expect(failure.errorMessage).to.equal('The Ask AI service did not answer within 1 second.'); + }); + }); +}); diff --git a/test/infrastructure/zip-service.test.ts b/test/infrastructure/zip-service.test.ts index 959f18e31..51614ec4b 100644 --- a/test/infrastructure/zip-service.test.ts +++ b/test/infrastructure/zip-service.test.ts @@ -80,6 +80,29 @@ describe('ZipService', () => { }); }); + describe('readEntry', () => { + it('reads one file of what archive packed, by its path inside', async () => { + write(path.join('__copilot', 'bundle.json'), '{"pages":[]}'); + write('index.html', ''); + (await new ZipService().archive(source, archive))._unsafeUnwrap(); + + expect(new ZipService().readEntry(archive, '__copilot/bundle.json')._unsafeUnwrap()).to.equal('{"pages":[]}'); + }); + + it('is null for a file the archive does not hold', async () => { + write('index.html', ''); + (await new ZipService().archive(source, archive))._unsafeUnwrap(); + + expect(new ZipService().readEntry(archive, '__copilot/bundle.json')._unsafeUnwrap()).to.be.null; + }); + + it('reports a file that is not an archive', () => { + fs.writeFileSync(archive.toString(), 'not a zip'); + + expect(new ZipService().readEntry(archive, 'index.html')._unsafeUnwrapErr()).to.not.be.empty; + }); + }); + describe('unArchive', () => { it('expands what archive packed', async () => { write('openapi.json', '{}'); diff --git a/test/portal-template/copilot-chat-links.test.ts b/test/portal-template/copilot-chat-links.test.ts new file mode 100644 index 000000000..56b3d3235 --- /dev/null +++ b/test/portal-template/copilot-chat-links.test.ts @@ -0,0 +1,33 @@ +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import { expect } from 'chai'; + +// `copilot.tsx` passes the service's paths through unchanged: right only while the router adds the base, once. +describe("the Ask AI chat's links under a base", () => { + it('links a source by its path within the portal, served under the base once', async () => { + // Their ES module builds: the CommonJS ones warn of a circular require when loaded. + const { createMemoryHistory, createRootRoute, createRouter, RouterProvider } = await import( + '@tanstack/react-router' + ); + const { TanstackProvider } = await import('fumadocs-core/framework/tanstack'); + const { AIChatSources } = await import('@fumadocs/ai-chat'); + const sources = [ + { url: '/guides/intro', title: 'Intro' }, + { url: 'https://status.example.com', title: 'Status' } + ]; + const root = createRootRoute({ + component: () => createElement(TanstackProvider, null, createElement(AIChatSources, { sources })) + }); + const router = createRouter({ + routeTree: root, + basepath: '/docs', + history: createMemoryHistory({ initialEntries: ['/docs/'] }) + }); + await router.load(); + + const html = renderToString(createElement(RouterProvider, { router })); + + expect(html).to.contain('href="/docs/guides/intro"'); + expect(html).to.contain('href="https://status.example.com"'); + }); +}); diff --git a/test/portal-template/copilot-client.test.ts b/test/portal-template/copilot-client.test.ts new file mode 100644 index 000000000..682ac898d --- /dev/null +++ b/test/portal-template/copilot-client.test.ts @@ -0,0 +1,177 @@ +import { expect } from 'chai'; +import { CopilotClient, questionMessage } from '../../portal-template/src/lib/copilot-client'; + +const COPILOT = { key: 'pk_live_abc', apiUrl: 'https://ask.example.com' }; + +interface Call { + url: string; + init: RequestInit; +} + +/** A service that answers each call in turn, recording what it was sent. */ +function service(...replies: (Response | (() => Response))[]) { + const calls: Call[] = []; + const send = (async (input: RequestInfo | URL, init: RequestInit = {}) => { + calls.push({ url: String(input), init }); + const reply = replies.shift(); + if (!reply) throw new Error('no more replies'); + return typeof reply === 'function' ? reply() : reply; + }) as typeof fetch; + return { calls, send }; +} + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const session = (token: string, inMs = 900_000) => + json({ token, expiresAt: new Date(Date.now() + inMs).toISOString() }); + +describe('the Ask AI client', () => { + it('opens a session with the public key, then asks with the session token only', async () => { + const { calls, send } = service(session('t1'), new Response('stream')); + const client = new CopilotClient(COPILOT, () => undefined, send); + const response = await client.fetch(client.chatAddress(), { method: 'POST', body: '{}' }); + expect(await response.text()).to.equal('stream'); + expect(calls[0]!.url).to.equal('https://ask.example.com/v1/sessions'); + expect(JSON.parse(calls[0]!.init.body as string)).to.deep.equal({ key: 'pk_live_abc' }); + expect(calls[1]!.url).to.equal('https://ask.example.com/v1/chat'); + expect(new Headers(calls[1]!.init.headers).get('authorization')).to.equal('Bearer t1'); + expect(calls[1]!.init.credentials).to.equal('omit'); + expect(calls[1]!.init.body).to.not.contain('pk_live_abc'); + }); + + it('reuses a session, and renews one that has expired', async () => { + const { calls, send } = service(session('t1'), new Response('a'), new Response('b')); + const client = new CopilotClient(COPILOT, () => undefined, send); + await client.fetch(client.chatAddress(), {}); + await client.fetch(client.chatAddress(), {}); + expect(calls.map((call) => call.url.split('/v1/')[1])).to.deep.equal(['sessions', 'chat', 'chat']); + + const renewing = service(session('old', 10_000), new Response('a'), session('new'), new Response('b')); + const renewed = new CopilotClient(COPILOT, () => undefined, renewing.send); + await renewed.fetch(renewed.chatAddress(), {}); + await renewed.fetch(renewed.chatAddress(), {}); + expect(new Headers(renewing.calls[3]!.init.headers).get('authorization')).to.equal('Bearer new'); + }); + + it('opens a new session once when the service says the token expired', async () => { + const { calls, send } = service( + session('t1'), + json({ code: 'session_expired', message: 'expired' }, 401), + session('t2'), + new Response('ok') + ); + const client = new CopilotClient(COPILOT, () => undefined, send); + expect(await (await client.fetch(client.chatAddress(), {})).text()).to.equal('ok'); + expect(new Headers(calls[3]!.init.headers).get('authorization')).to.equal('Bearer t2'); + }); + + it("shows the service's own message when it refuses a question", async () => { + const { send } = service( + session('t1'), + json({ code: 'quota_exceeded', message: 'Limit reached. Search still works.' }, 429) + ); + const client = new CopilotClient(COPILOT, () => undefined, send); + const response = await client.fetch(client.chatAddress(), {}); + expect(response.status).to.equal(429); + expect(await response.text()).to.equal('Limit reached. Search still works.'); + }); + + it('hides Ask AI when the service turns the portal away', async () => { + for (const code of ['copilot_disabled', 'invalid_key', 'origin_not_allowed']) { + let hidden = false; + const { send } = service(json({ code, message: 'no' }, 403)); + const client = new CopilotClient(COPILOT, () => (hidden = true), send); + let failed = false; + await client.fetch(client.chatAddress(), {}).catch(() => (failed = true)); + expect(failed, code).to.be.true; + expect(hidden, code).to.be.true; + } + }); + + it('opens the session ahead of the first question, which then uses it', async () => { + const { calls, send } = service(session('t1'), new Response('ok')); + const client = new CopilotClient(COPILOT, () => undefined, send); + + await client.prepare(); + await client.fetch(client.chatAddress(), {}); + + expect(calls.map((call) => call.url.split('/v1/')[1])).to.deep.equal(['sessions', 'chat']); + expect(new Headers(calls[1]!.init.headers).get('authorization')).to.equal('Bearer t1'); + }); + + it('hides Ask AI as the session opens, before any question, when the service turns the portal away', async () => { + let hidden = false; + const { send } = service(json({ code: 'invalid_key', message: 'no' }, 403)); + const client = new CopilotClient(COPILOT, () => (hidden = true), send); + + await client.prepare(); + + expect(hidden).to.be.true; + }); + + it('leaves any other failure to open the session to the first question, which tries again', async () => { + const { calls, send } = service( + () => { + throw new TypeError('Failed to fetch'); + }, + session('t1'), + new Response('ok') + ); + let hidden = false; + const client = new CopilotClient(COPILOT, () => (hidden = true), send); + + await client.prepare(); + + expect(hidden).to.be.false; + expect(await (await client.fetch(client.chatAddress(), {})).text()).to.equal('ok'); + expect(calls).to.have.length(3); + }); + + it('says the service is unavailable when it cannot be reached, and tries again next time', async () => { + const { calls, send } = service( + () => { + throw new TypeError('Failed to fetch'); + }, + session('t1'), + new Response('ok') + ); + const client = new CopilotClient(COPILOT, () => undefined, send); + let message = ''; + await client.fetch(client.chatAddress(), {}).catch((error: Error) => (message = error.message)); + expect(message).to.contain('unavailable'); + expect(await (await client.fetch(client.chatAddress(), {})).text()).to.equal('ok'); + expect(calls).to.have.length(3); + }); + + it('continues the thread the service started, and starts afresh with a new chat', () => { + const client = new CopilotClient(COPILOT, () => undefined, service().send); + expect(client.questionBody([{ id: 'q1' }], 'submit-message')).to.deep.equal({ + threadId: null, + trigger: 'submit-message', + message: { id: 'q1' } + }); + client.noteData({ type: 'data-thread', data: { threadId: 'th-1' } }); + expect(client.questionBody([{ id: 'q1' }, { id: 'a1' }, { id: 'q2' }], 'submit-message').threadId).to.equal('th-1'); + expect(client.questionBody([{ id: 'q9' }], 'submit-message').threadId).to.be.null; + }); +}); + +describe("the Ask AI question's page", () => { + it('names the page a question is asked from by its address, without its query or fragment', () => { + const message = questionMessage('How do I log in?', { + href: 'https://docs.example.com/docs/oauth/callback?code=secret&state=xyz#token=abc', + title: 'Signing in' + }); + + expect(message).to.deep.equal({ + role: 'user', + parts: [ + { + type: 'data-client', + data: { location: 'https://docs.example.com/docs/oauth/callback', title: 'Signing in' } + }, + { type: 'text', text: 'How do I log in?' } + ] + }); + expect(JSON.stringify(message)).to.not.contain('secret').and.not.contain('abc'); + }); +}); diff --git a/test/portal-template/prerender-pages.test.ts b/test/portal-template/prerender-pages.test.ts index c576287a1..238f08637 100644 --- a/test/portal-template/prerender-pages.test.ts +++ b/test/portal-template/prerender-pages.test.ts @@ -3,6 +3,10 @@ import os from 'os'; import path from 'path'; import { expect } from 'chai'; import { prerenderPages } from '../../portal-template/prerender-pages'; +import type { PortalCopilot } from '../../portal-template/src/lib/portal-types'; +import { COPILOT_PAGES_FILE } from '../../src/types/portal/copilot/generated-portal-context'; + +const COPILOT: PortalCopilot = { key: 'pk_test', apiUrl: 'https://ask.apimatic.io', suggestions: [] }; // The list this builds is the whole of what a static build writes: TanStack's crawler is off, // so a URL missing here is a page that is never emitted, however many things link to it. @@ -17,10 +21,15 @@ describe('prerenderPages', () => { }; const write = (relative: string, body = '# page\n') => writeIn(contentDir, relative, body); - const urlsFor = async (siteUrl: string | null = null, base = '/', specs: Record = {}) => { + const urlsFor = async ( + siteUrl: string | null = null, + base = '/', + specs: Record = {}, + copilot: PortalCopilot | null = null + ) => { const pages = await prerenderPages( { base, specs, specDir: '', codeSamples: null, contentDir, generatedDir, staticDir: null, downloadsDir: null }, - siteUrl + { siteUrl, copilot } ); return pages.map((page) => page.path); }; @@ -106,6 +115,16 @@ describe('prerenderPages', () => { expect(await urlsFor('https://docs.test')).to.include('/robots.txt'); }); + it('emits the pages for Ask AI only for a portal that offers it, where the CLI reads them', async () => { + write('index.md'); + + expect(await urlsFor()).to.not.include(`/${COPILOT_PAGES_FILE}`); + const urls = await urlsFor(null, '/', {}, COPILOT); + expect(urls).to.include(`/${COPILOT_PAGES_FILE}`); + expect(urls).to.not.include(`/${COPILOT_PAGES_FILE}.md`); + expect(await urlsFor('https://docs.test/docs', '/docs/', {}, COPILOT)).to.include(`/docs/${COPILOT_PAGES_FILE}`); + }); + it('lists the OAuth callback, without a Markdown twin', async () => { write('index.md'); diff --git a/test/prompts/portal/copilot/sync.test.ts b/test/prompts/portal/copilot/sync.test.ts new file mode 100644 index 000000000..6bda2893f --- /dev/null +++ b/test/prompts/portal/copilot/sync.test.ts @@ -0,0 +1,63 @@ +import { stripVTControlCharacters } from 'node:util'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { log } from '@clack/prompts'; +import { ServiceError } from '../../../../src/infrastructure/service-error.js'; +import { PortalCopilotSyncPrompts } from '../../../../src/prompts/portal/copilot/sync.js'; +import { ServiceAddress } from '../../../../src/types/portal/config/service-address.js'; + +describe('PortalCopilotSyncPrompts', () => { + const API_URL = 'https://ask.apimatic.io'; + const SERVICE = ServiceAddress.parse(API_URL, 'apiUrl')._unsafeUnwrap(); + const prompts = new PortalCopilotSyncPrompts(); + let error: sinon.SinonStub; + + // Without its colours, which picocolors adds on Windows and in CI whatever the terminal. + const printed = () => error.getCalls().map((call) => stripVTControlCharacters(String(call.args[0]))); + + beforeEach(() => { + error = sinon.stub(log, 'error'); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('tells a timed-out upload to sync again, rather than to check the address', () => { + prompts.uploadFailed(ServiceError.timeout('The upload did not finish within 2 minutes.'), SERVICE); + + const [message] = printed(); + expect(message).to.contain('The upload did not finish within 2 minutes.'); + expect(message).to.contain('sync again'); + expect(message).to.not.contain('could not be reached'); + expect(message).to.not.contain('keeps indexing'); + }); + + it('tells timed-out indexing that the service keeps indexing', () => { + prompts.indexingFailed(ServiceError.timeout('Indexing did not finish within 10 minutes.'), SERVICE); + + const [message] = printed(); + expect(message).to.contain('Indexing did not finish within 10 minutes.'); + expect(message).to.contain('keeps indexing'); + }); + + it("words failed indexing itself, with the service's reason when it gives one", () => { + prompts.indexingFailed(ServiceError.processingFailed('Embedding provider unavailable.'), SERVICE); + prompts.indexingFailed(ServiceError.processingFailed(null), SERVICE); + + expect(printed()).to.deep.equal([ + 'The Ask AI service could not index the pages: Embedding provider unavailable. ' + + 'Ask AI answers from the previous pages until a sync is indexed.', + 'The Ask AI service could not index the pages. Ask AI answers from the previous pages until a sync is indexed.' + ]); + }); + + it('tells either step that a service it could not reach may be at the wrong address', () => { + prompts.uploadFailed(ServiceError.NetworkError, SERVICE); + prompts.indexingFailed(ServiceError.NetworkError, SERVICE); + + for (const message of printed()) { + expect(message).to.contain(`The Ask AI service at ${API_URL} could not be reached`); + } + }); +}); diff --git a/test/prompts/portal/generate.test.ts b/test/prompts/portal/generate.test.ts index 93a216c14..645c13324 100644 --- a/test/prompts/portal/generate.test.ts +++ b/test/prompts/portal/generate.test.ts @@ -1,11 +1,69 @@ import { stripVTControlCharacters } from 'node:util'; import { expect } from 'chai'; import sinon from 'sinon'; +import { log } from '@clack/prompts'; import { PortalGeneratePrompts } from '../../../src/prompts/portal/generate.js'; import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { SiteAddress } from '../../../src/types/portal/config/site-address.js'; describe('PortalGeneratePrompts', () => { + describe('when Ask AI gets no pages', () => { + const warned = (say: (prompts: PortalGeneratePrompts) => void) => { + const warn = sinon.stub(log, 'warn'); + try { + say(new PortalGeneratePrompts()); + } finally { + warn.restore(); + } + return stripVTControlCharacters(String(warn.firstCall.args[0])); + }; + + it('tells a portal with no pages that it has none', () => { + expect(warned((prompts) => prompts.copilotPagesNotFound())).to.equal( + 'The portal has no pages for Ask AI to answer from, so none were added to it.' + ); + }); + + it('tells a build that wrote none to generate again, not to look through the docs', () => { + const message = warned((prompts) => prompts.copilotPagesNotBuilt()); + + expect(message).to.contain('The build wrote no pages for Ask AI'); + expect(message).to.contain('Generate the portal again'); + expect(message).to.contain('support@apimatic.io'); + expect(message).to.not.contain('has no pages'); + }); + }); + + describe('how Ask AI gets the pages', () => { + const printed = (url: string | null) => { + const site = url === null ? null : SiteAddress.parse(url, 'portal.site.url')._unsafeUnwrap(); + const info = sinon.stub(log, 'info'); + try { + new PortalGeneratePrompts().copilotUpdatesAfterDeploy(site); + } finally { + info.restore(); + } + return stripVTControlCharacters(String(info.firstCall.args[0])); + }; + + it('says the service picks them up from a portal with an address, with sync for one it cannot reach', () => { + const message = printed('https://example.com/docs'); + + expect(message).to.contain( + 'picks up these pages by itself once the portal is deployed at https://example.com/docs' + ); + expect(message).to.contain('cannot reach it there, run apimatic portal copilot sync after deploying'); + }); + + it('sends a portal without an address to sync, since the service cannot find it', () => { + const message = printed(null); + + expect(message).to.contain('Deploy the portal, then run apimatic portal copilot sync'); + expect(message).to.contain('cannot find the portal by itself'); + expect(message).to.not.contain('by itself once'); + }); + }); + describe('the next steps', () => { const portal = new DirectoryPath('portal'); diff --git a/test/resources/portal-inputs/copilot/src/apimatic.json b/test/resources/portal-inputs/copilot/src/apimatic.json new file mode 100644 index 000000000..7407717cf --- /dev/null +++ b/test/resources/portal-inputs/copilot/src/apimatic.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://cdn.jsdelivr.net/npm/@apimatic/cli@beta/apimatic.schema.json", + "schemaVersion": 1, + "portal": { + "site": { "name": "Ask AI Pets" }, + "ai": { + "copilot": { + "key": "pk_test_e2e", + "apiUrl": "https://ask.apimatic.io", + "suggestions": ["How do I list pets?"] + } + } + }, + "languages": { + "typescript": {} + } +} diff --git "a/test/resources/portal-inputs/copilot/src/content/guides/caf\303\251.md" "b/test/resources/portal-inputs/copilot/src/content/guides/caf\303\251.md" new file mode 100644 index 000000000..8760aa218 --- /dev/null +++ "b/test/resources/portal-inputs/copilot/src/content/guides/caf\303\251.md" @@ -0,0 +1,6 @@ +--- +title: Café +description: A page whose name is not ASCII. +--- + +Pets can visit the café. diff --git a/test/resources/portal-inputs/copilot/src/content/guides/nav.json b/test/resources/portal-inputs/copilot/src/content/guides/nav.json new file mode 100644 index 000000000..37e658726 --- /dev/null +++ b/test/resources/portal-inputs/copilot/src/content/guides/nav.json @@ -0,0 +1,4 @@ +{ + "title": "Guides", + "pages": ["café"] +} diff --git a/test/resources/portal-inputs/copilot/src/content/guides/unlisted.md b/test/resources/portal-inputs/copilot/src/content/guides/unlisted.md new file mode 100644 index 000000000..f0ccee532 --- /dev/null +++ b/test/resources/portal-inputs/copilot/src/content/guides/unlisted.md @@ -0,0 +1,5 @@ +--- +title: Unlisted +--- + +A page the navigation leaves out, which Ask AI still answers from. diff --git a/test/resources/portal-inputs/copilot/src/content/index.md b/test/resources/portal-inputs/copilot/src/content/index.md new file mode 100644 index 000000000..ed45dba6a --- /dev/null +++ b/test/resources/portal-inputs/copilot/src/content/index.md @@ -0,0 +1,6 @@ +--- +title: Ask AI Pets +description: Pets, with answers. +--- + +The pets API, with Ask AI to answer questions about it. diff --git a/test/resources/portal-inputs/copilot/src/spec/pets.json b/test/resources/portal-inputs/copilot/src/spec/pets.json new file mode 100644 index 000000000..6bcd22492 --- /dev/null +++ b/test/resources/portal-inputs/copilot/src/spec/pets.json @@ -0,0 +1,42 @@ +{ + "openapi": "3.0.3", + "info": { "title": "Pets", "version": "1.0.0", "description": "Pets, with a brand of their own." }, + "servers": [{ "url": "https://pets.example.com" }], + "tags": [{ "name": "pets" }], + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "tags": ["pets"], + "summary": "List pets", + "responses": { "200": { "description": "The pets." } } + }, + "post": { + "operationId": "createPet", + "tags": ["pets"], + "summary": "Create a pet", + "deprecated": true, + "responses": { "201": { "description": "The pet, created." } } + } + }, + "/pets/{petId}/photo": { + "put": { + "operationId": "uploadPetPhoto", + "tags": ["pets"], + "summary": "Upload a pet's photo", + "parameters": [{ "name": "petId", "in": "path", "required": true, "schema": { "type": "string" } }], + "requestBody": { "required": true, "content": { "image/*": { "schema": { "type": "string", "format": "binary" } } } }, + "responses": { "204": { "description": "The photo, stored." } } + } + }, + "/pets/audit": { + "get": { + "operationId": "auditPets", + "tags": ["pets"], + "summary": "Audit the pets", + "x-internal": true, + "responses": { "200": { "description": "The audit." } } + } + } + } +} diff --git a/test/types/apimatic-config/schema.test.ts b/test/types/apimatic-config/schema.test.ts index 4626ee62e..a50156c08 100644 --- a/test/types/apimatic-config/schema.test.ts +++ b/test/types/apimatic-config/schema.test.ts @@ -79,6 +79,39 @@ describe('apimatic.schema.json', () => { }); }); + // Each address through both, as an editor and `portal generate` would read it. + it('agrees with the parser on every Ask AI service address, accepted or not', () => { + for (const apiUrl of [ + 'https://ask.apimatic.io', + 'HTTPS://ASK.APIMATIC.IO:8443', + 'https://ask.staging.apimatic.io/', + 'https://ask.apimatic.io:443', + 'http://localhost:8787', + 'https://localhost:65535', + 'http://127.0.0.1:0', + 'http://[::1]:8787', + 'https://ask.example.com', + 'http://ask.apimatic.io', + 'https://ask.apimatic.io/v1', + 'https://user@ask.apimatic.io', + 'http://localhost:@evil.example/', + 'http://localhost:99999', + 'http://localhost:65536', + 'http://localhost:08787', + 'https://x.y:1.apimatic.io', + 'https://.apimatic.io', + 'https://ask..apimatic.io', + 'http://127.1:8787', + 'http://[0:0::1]:8787', + 'https://ask%2eapimatic.io', + 'https://ask.apimatic.io:', + 'http://ask.localhost' + ]) { + const block = { ai: { copilot: { key: 'pk_test', apiUrl } } }; + expect(schemaVerdict({ portal: block }).valid, apiUrl).to.equal(portalAccepts(block)); + } + }); + describe('agrees with the parser on the portal block', () => { const valid: [string, object][] = [ ['an empty block', {}], @@ -117,10 +150,53 @@ describe('apimatic.schema.json', () => { ['a link through a parent segment', { navigation: { links: [{ label: 'Start', url: '/guides/../start' }] } }], ['a link with two slashes in its query', { navigation: { links: [{ label: 'Next', url: '/start?next=//x' }] } }], ['a context plugin hosted elsewhere', { pluginUrl: 'https://plugins.acme.test/calc.zip?v=2' }], - ['a plugin address with several query parameters', { pluginUrl: 'https://s3.test/calc.zip?a=1&b=2' }] + ['a plugin address with several query parameters', { pluginUrl: 'https://s3.test/calc.zip?a=1&b=2' }], + ["APIMatic's Ask AI service", { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' } } }], + [ + "APIMatic's Ask AI service, in capitals", + { ai: { copilot: { key: 'pk_test', apiUrl: 'HTTPS://ASK.APIMATIC.IO:8443' } } } + ], + [ + "APIMatic's Ask AI service, with a trailing slash", + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io/' } } } + ], + ['an Ask AI service on this machine', { ai: { copilot: { key: 'pk_test', apiUrl: 'http://localhost:8787' } } }] ]; const invalid: [string, object][] = [ + [ + 'an Ask AI service address with a path', + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io/v1' } } } + ], + [ + 'an Ask AI service address with a query', + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io/?region=eu' } } } + ], + [ + 'an Ask AI service address with credentials', + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://user@ask.apimatic.io' } } } + ], + [ + 'an Ask AI service APIMatic does not run', + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.example.com' } } } + ], + [ + "APIMatic's Ask AI service over http", + { ai: { copilot: { key: 'pk_test', apiUrl: 'http://ask.apimatic.io' } } } + ], + [ + 'an Ask AI service on a host ending like APIMatic', + { ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io.example.com' } } } + ], + [ + 'an Ask AI service on this machine in name only', + { ai: { copilot: { key: 'pk_test', apiUrl: 'http://localhost:@evil.example/' } } } + ], + // Resolved through DNS on some systems, so it may not be this machine. + [ + 'an Ask AI service over http on a .localhost name', + { ai: { copilot: { key: 'pk_test', apiUrl: 'http://ask.localhost' } } } + ], ['an unknown namespace', { theme: {} }], ['a key from the flat block', { title: 'Calc' }], ['a namespace that is not an object', { site: 'Calc' }], diff --git a/test/types/portal/config/service-address.test.ts b/test/types/portal/config/service-address.test.ts new file mode 100644 index 000000000..b95ef904f --- /dev/null +++ b/test/types/portal/config/service-address.test.ts @@ -0,0 +1,82 @@ +import { expect } from 'chai'; +import { ServiceAddress } from '../../../../src/types/portal/config/service-address'; + +describe('ServiceAddress', () => { + const SETTING = 'portal.ai.copilot.apiUrl'; + + const address = (value: unknown) => ServiceAddress.parse(value, SETTING)._unsafeUnwrap(); + const refusal = (value: unknown) => ServiceAddress.parse(value, SETTING)._unsafeUnwrapErr(); + + const NOT_APIMATIC = + "'portal.ai.copilot.apiUrl' must be the address of APIMatic's Ask AI service, starting with 'https://' and " + + "ending in 'apimatic.io', or of one on this machine, such as 'http://localhost:8787'."; + const NOT_ALONE = + "'portal.ai.copilot.apiUrl' must be the service's address alone, written in full, without a path, query or " + + "credentials, for example 'https://ask.apimatic.io'."; + + describe('parse', () => { + it("reads APIMatic's own service, over HTTPS, or one on this machine, as its origin", () => { + const cases: [string, string][] = [ + ['https://ask.apimatic.io', 'https://ask.apimatic.io'], + ['https://ask.apimatic.io/', 'https://ask.apimatic.io'], + ['https://apimatic.io', 'https://apimatic.io'], + ['https://ask.staging.apimatic.io:8443', 'https://ask.staging.apimatic.io:8443'], + ['HTTPS://ASK.APIMATIC.IO', 'https://ask.apimatic.io'], + ['http://localhost:8787', 'http://localhost:8787'], + ['https://localhost:8787/', 'https://localhost:8787'], + ['http://127.0.0.1:8787', 'http://127.0.0.1:8787'], + ['http://[::1]:8787', 'http://[::1]:8787'], + ['https://ask.apimatic.io:443', 'https://ask.apimatic.io'], + ['http://localhost:80/', 'http://localhost'] + ]; + for (const [value, origin] of cases) { + expect(address(value).toString(), value).to.equal(origin); + } + }); + + // `sync` sends the APIMatic login there, so no service but APIMatic's, or one on this machine, is accepted. + it('refuses any other service, or APIMatic over plain HTTP', () => { + for (const value of [ + 'https://ask.example.com', + 'http://ask.apimatic.io', + 'https://ask.apimatic.io.example.com', + 'https://evilapimatic.io', + 'https://.apimatic.io', + 'https://ask..apimatic.io', + 'http://localhost:99999', + 'https://ask.apimatic.io@evil.example', + 'http://localhost:@evil.example/', + 'http://localhost.example.com', + // `*.localhost` is resolved through DNS on some systems, so it may not be this machine. + 'http://ask.localhost', + 'javascript:alert(1)', + '', + 7 + ]) { + expect(refusal(value), String(value)).to.deep.equal([NOT_APIMATIC]); + } + }); + + // The CLI and the chat add the service's own paths, so a path of the address's own would be dropped or doubled. + it('refuses an address with a path, a query, a fragment or credentials', () => { + for (const value of [ + 'https://ask.apimatic.io/v1', + 'https://ask.apimatic.io\\v1', + 'https://ask.apimatic.io/?', + 'https://ask.apimatic.io/?region=eu', + 'https://ask.apimatic.io#', + 'https://user@ask.apimatic.io', + 'https://user:secret@ask.apimatic.io', + 'http://localhost:8787/api', + // Addresses the URL parser would rewrite: refused, so what is accepted is what was written. + 'http://127.1:8787', + 'http://[0:0::1]:8787', + 'https://ask%2eapimatic.io', + 'https://ask.apimatic.io:', + 'http://localhost:08787' + ]) { + expect(refusal(value), value).to.deep.equal([NOT_ALONE]); + } + }); + }); +}); diff --git a/test/types/portal/copilot/copilot-bundle.test.ts b/test/types/portal/copilot/copilot-bundle.test.ts new file mode 100644 index 000000000..1698a86c3 --- /dev/null +++ b/test/types/portal/copilot/copilot-bundle.test.ts @@ -0,0 +1,99 @@ +import { createHash } from 'node:crypto'; +import { gunzipSync } from 'node:zlib'; +import { expect } from 'chai'; +import { SiteAddress } from '../../../../src/types/portal/config/site-address'; +import { BundlePage, CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; + +const AT = new Date('2026-10-14T12:00:00.000Z'); + +const PAGES: BundlePage[] = [ + { path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }, + { path: '/guides/intro', title: 'Intro', description: null, markdown: '# Intro' } +]; + +const at = (url: string) => SiteAddress.parse(url, 'url')._unsafeUnwrap(); + +const documentOf = (bundle: CopilotBundle) => JSON.parse(gunzipSync(bundle.gzipped()).toString('utf8')) as unknown; + +describe('CopilotBundle', () => { + it("holds the pages in the service's format, a page without a description leaving the key out", () => { + const bundle = CopilotBundle.of(PAGES, null, '9.9.9', AT); + + expect(bundle.pageCount()).to.equal(2); + expect(documentOf(bundle)).to.deep.equal({ + formatVersion: 1, + generator: { name: 'apimatic-cli', version: '9.9.9' }, + generatedAt: '2026-10-14T12:00:00.000Z', + siteUrl: null, + basePath: '/', + pages: [ + { path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }, + { path: '/guides/intro', title: 'Intro', markdown: '# Intro' } + ] + }); + }); + + it('names the address a portal served under a path is at, and that path', () => { + const bundle = CopilotBundle.of(PAGES, at('https://example.com/docs'), '9.9.9', AT); + + expect(documentOf(bundle)).to.deep.include({ siteUrl: 'https://example.com/docs', basePath: '/docs/' }); + }); + + describe('parse', () => { + it('reads back a bundle, keeping its exact bytes', () => { + const json = CopilotBundle.of(PAGES, at('https://example.com/docs'), '9.9.9', AT).toJson(); + + const bundle = CopilotBundle.parse(json)!; + + expect(bundle.toJson()).to.equal(json); + expect(bundle.pageCount()).to.equal(2); + expect(bundle.buildManifest()).to.equal(CopilotBundle.parse(json)!.buildManifest()); + }); + + it('refuses anything that is not a bundle of this format', () => { + const valid = JSON.parse(CopilotBundle.of(PAGES, null, '9.9.9', AT).toJson()) as Record; + + for (const [label, json] of [ + ['text that is not JSON', '{'], + ['another format version', JSON.stringify({ ...valid, formatVersion: 2 })], + ['pages that are not a list', JSON.stringify({ ...valid, pages: {} })], + ['a page without its Markdown', JSON.stringify({ ...valid, pages: [{ path: '/', title: 'Welcome' }] })], + ['a page that is not an object', JSON.stringify({ ...valid, pages: ['/'] })] + ]) { + expect(CopilotBundle.parse(json!), label).to.be.null; + } + }); + }); + + describe('build ID', () => { + const buildId = (pages = PAGES, site: SiteAddress | null = null, cliVersion = '9.9.9', when = AT) => + (JSON.parse(CopilotBundle.of(pages, site, cliVersion, when).buildManifest()) as { buildId: string }).buildId; + + // The service fetches a deployed bundle.json and refuses it unless it hashes to this ID. + it("is the SHA-256 of the bundle's exact bytes, in format version 1", () => { + const bundle = CopilotBundle.of(PAGES, null, '9.9.9', AT); + + expect(JSON.parse(bundle.buildManifest())).to.deep.equal({ + formatVersion: 1, + buildId: createHash('sha256').update(bundle.toJson()).digest('hex') + }); + }); + + // The service finds the same pages then and indexes nothing again; it only fetches the bundle once more. + it('changes when the same pages are generated again, as the bytes do', () => { + expect(buildId(PAGES, null, '9.9.9', new Date('2027-01-01T00:00:00.000Z'))).to.not.equal(buildId()); + }); + + it('changes with a page', () => { + const changed = [{ ...PAGES[0]!, markdown: '# Welcome back' }, PAGES[1]!]; + + expect(buildId(changed)).to.not.equal(buildId()); + }); + + it('changes with the address the pages are served at', () => { + expect(buildId(PAGES, at('https://example.com/docs'))).to.not.equal( + buildId(PAGES, at('https://example.com/api')) + ); + }); + }); +}); diff --git a/test/types/portal/copilot/generated-portal-context.test.ts b/test/types/portal/copilot/generated-portal-context.test.ts new file mode 100644 index 000000000..83effa8e8 --- /dev/null +++ b/test/types/portal/copilot/generated-portal-context.test.ts @@ -0,0 +1,163 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { FileService } from '../../../../src/infrastructure/file-service'; +import { ZipService } from '../../../../src/infrastructure/zip-service'; +import { FilePath } from '../../../../src/types/file/filePath'; +import { FileName } from '../../../../src/types/file/fileName'; +import { DirectoryPath } from '../../../../src/types/file/directoryPath'; +import { BundlePage, CopilotBundle } from '../../../../src/types/portal/copilot/copilot-bundle'; +import { + COPILOT_PAGES_FILE, + GeneratedPortalContext +} from '../../../../src/types/portal/copilot/generated-portal-context'; + +const PAGES: BundlePage[] = [ + { path: '/', title: 'Welcome', description: 'Start here', markdown: '# Welcome' }, + { path: '/guides/caf%C3%A9', title: 'Café', description: null, markdown: '# Café' } +]; + +const BUNDLE = CopilotBundle.of(PAGES, null, '9.9.9', new Date('2026-10-14T12:00:00.000Z')); + +describe('GeneratedPortalContext', () => { + let root: string; + let portal: GeneratedPortalContext; + + const write = (relative: string, contents: string) => { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + }; + const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + const exists = (relative: string) => fs.existsSync(path.join(root, relative)); + + /** Has `FileService` fail to read the files whose path ends with `suffix`. */ + const unreadable = (suffix: string) => { + const getContents = FileService.prototype.getContents; + return sinon.stub(FileService.prototype, 'getContents').callsFake(function (this: FileService, file) { + return file.toString().endsWith(suffix) + ? Promise.reject(new Error('EACCES: permission denied')) + : getContents.call(this, file); + }); + }; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'generated-portal-')); + portal = new GeneratedPortalContext(new DirectoryPath(root)); + }); + + afterEach(() => { + sinon.restore(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + describe('takePages', () => { + it('reads the pages the build wrote for Ask AI, and removes them so they are not deployed', async () => { + write(COPILOT_PAGES_FILE, JSON.stringify(PAGES)); + + expect((await portal.takePages())._unsafeUnwrap()).to.deep.equal(PAGES); + expect(exists(COPILOT_PAGES_FILE)).to.be.false; + }); + + it('is null for a build that wrote none, as one of a portal without Ask AI', async () => { + expect((await portal.takePages())._unsafeUnwrap()).to.be.null; + }); + + it('reports pages it cannot make out, and still removes them', async () => { + for (const contents of ['{', '{}', JSON.stringify([{ path: '/', title: 'Welcome' }])]) { + write(COPILOT_PAGES_FILE, contents); + + expect((await portal.takePages()).isErr(), contents).to.be.true; + expect(exists(COPILOT_PAGES_FILE), contents).to.be.false; + } + }); + + it('reports pages it cannot read rather than throwing, and still removes them', async () => { + write(COPILOT_PAGES_FILE, JSON.stringify(PAGES)); + unreadable('copilot-pages.json'); + + expect((await portal.takePages())._unsafeUnwrapErr()).to.contain('EACCES: permission denied'); + expect(exists(COPILOT_PAGES_FILE)).to.be.false; + }); + }); + + describe('publish', () => { + it('writes the bundle as plain JSON beside its build ID', async () => { + expect((await portal.publish(BUNDLE)).isOk()).to.be.true; + + expect(read('__copilot/bundle.json')).to.equal(BUNDLE.toJson()); + expect(read('__copilot/build.json')).to.equal(BUNDLE.buildManifest()); + // As the service checks a deployed portal: the build ID is the hash of bundle.json as written. + const written = fs.readFileSync(path.join(root, '__copilot', 'bundle.json')); + expect(JSON.parse(read('__copilot/build.json')).buildId).to.equal( + createHash('sha256').update(written).digest('hex') + ); + }); + + it('reports a portal it cannot write to rather than throwing', async () => { + write('__copilot', 'a file where the directory goes'); + + expect((await portal.publish(BUNDLE)).isErr()).to.be.true; + }); + }); + + describe('bundle', () => { + it('reads back the bundle as it was published, byte for byte', async () => { + await portal.publish(BUNDLE); + + const bundle = (await portal.bundle())._unsafeUnwrap()!; + + expect(bundle.toJson()).to.equal(BUNDLE.toJson()); + expect(bundle.buildManifest()).to.equal(BUNDLE.buildManifest()); + }); + + it('is null for a portal without one', async () => { + expect((await portal.bundle())._unsafeUnwrap()).to.be.null; + }); + + describe('of a portal saved as an archive', () => { + /** Saves what is in `built` as `portal generate --zip` does: as the portal's only file. */ + const zipPortal = async (built: string) => { + const archive = new FilePath(new DirectoryPath(root), new FileName('portal.zip')); + (await new ZipService().archive(new DirectoryPath(path.join(root, built)), archive))._unsafeUnwrap(); + fs.rmSync(path.join(root, built), { recursive: true }); + }; + + it('reads the bundle from inside the archive', async () => { + await new GeneratedPortalContext(new DirectoryPath(path.join(root, 'built'))).publish(BUNDLE); + await zipPortal('built'); + + expect((await portal.bundle())._unsafeUnwrap()!.toJson()).to.equal(BUNDLE.toJson()); + }); + + it('is null for an archive without one', async () => { + write('built/index.html', ''); + await zipPortal('built'); + + expect((await portal.bundle())._unsafeUnwrap()).to.be.null; + }); + + it('reports an archive it cannot read', async () => { + write('portal.zip', 'not a zip'); + + expect((await portal.bundle()).isErr()).to.be.true; + }); + }); + + it('reports a bundle it cannot make out', async () => { + write('__copilot/bundle.json', '{ "formatVersion": 2 }'); + + expect((await portal.bundle()).isErr()).to.be.true; + }); + + it('reports a bundle it cannot read rather than throwing', async () => { + await portal.publish(BUNDLE); + unreadable('bundle.json'); + + expect((await portal.bundle())._unsafeUnwrapErr()).to.contain('EACCES: permission denied'); + }); + }); +}); diff --git a/test/types/portal/portal-config.test.ts b/test/types/portal/portal-config.test.ts index 6f0e692f3..ed1a0bb87 100644 --- a/test/types/portal/portal-config.test.ts +++ b/test/types/portal/portal-config.test.ts @@ -393,6 +393,86 @@ describe('PortalConfig', () => { expect(config({ ai: { pageActions: false } }).identity().pageActions).to.be.false; expect(errorsOf({ ai: { pageActions: 'no' } })).to.deep.equal(["'portal.ai.pageActions' must be true or false."]); }); + + describe('copilot', () => { + const PUBLIC_KEY = `askai_pk_ab12cd34_${'A'.repeat(43)}`; + + it('offers the Ask AI chat only when the block sets one up', () => { + expect(config({}).identity().copilot).to.be.null; + const copilot = { key: PUBLIC_KEY, apiUrl: 'https://ask.apimatic.io/' }; + expect(config({ ai: { copilot } }).identity().copilot).to.deep.equal({ + key: PUBLIC_KEY, + apiUrl: 'https://ask.apimatic.io', + suggestions: [] + }); + expect(config({ ai: { copilot } }).toJSON().ai.copilot).to.deep.equal({ + key: PUBLIC_KEY, + apiUrl: 'https://ask.apimatic.io' + }); + expect(config({}).toJSON().ai).to.not.have.property('copilot'); + }); + + it('offers up to three starter questions, and writes them back only when set', () => { + const suggestions = [' How do I authenticate? ', 'How do I create an order?']; + const copilot = { key: PUBLIC_KEY, apiUrl: 'https://ask.apimatic.io', suggestions }; + expect(config({ ai: { copilot } }).identity().copilot?.suggestions).to.deep.equal([ + 'How do I authenticate?', + 'How do I create an order?' + ]); + expect(config({ ai: { copilot } }).toJSON().ai.copilot?.suggestions).to.have.length(2); + const tooMany = { key: PUBLIC_KEY, apiUrl: 'https://ask.apimatic.io', suggestions: ['a', 'b', 'c', 'd'] }; + expect(errorsOf({ ai: { copilot: tooMany } })).to.deep.equal([ + "'portal.ai.copilot.suggestions' must be a list of up to 3 questions, each a non-empty string of at most 120 characters." + ]); + expect(errorsOf({ ai: { copilot: { ...tooMany, suggestions: [''] } } })).to.have.length(1); + }); + + // The service validates the key and owns its design, so any non-empty key is passed through. + it('accepts any key, whatever its layout', () => { + const copilot = { key: ' v2-key_Zx9 ', apiUrl: 'https://ask.apimatic.io' }; + expect(config({ ai: { copilot } }).identity().copilot?.key).to.equal('v2-key_Zx9'); + }); + + it('names every missing or malformed setting', () => { + expect(errorsOf({ ai: { copilot: { apiUrl: 'javascript:alert(1)', extra: true } } })).to.deep.equal([ + "'portal.ai.copilot.extra' is not a 'portal' setting.", + "'portal.ai.copilot.key' must be a non-empty string.", + "'portal.ai.copilot.apiUrl' must be the address of APIMatic's Ask AI service, starting with 'https://' and ending in 'apimatic.io', or of one on this machine, such as 'http://localhost:8787'." + ]); + expect(errorsOf({ ai: { copilot: 'yes' } })).to.deep.equal(["'portal.ai.copilot' must be a JSON object."]); + }); + + // `sync` sends the APIMatic login there, so no service but APIMatic's, or one on this machine, is accepted. + it("accepts only APIMatic's own service, over HTTPS, or one on this machine", () => { + const at = (apiUrl: string) => ({ ai: { copilot: { key: PUBLIC_KEY, apiUrl } } }); + for (const accepted of [ + 'https://ask.apimatic.io', + 'https://apimatic.io', + 'https://ask.staging.apimatic.io:8443', + 'HTTPS://ASK.APIMATIC.IO', + 'http://localhost:8787', + 'https://localhost:8787', + 'http://127.0.0.1:8787', + 'http://[::1]:8787' + ]) { + expect(config(at(accepted)).identity().copilot?.apiUrl, accepted).to.equal(new URL(accepted).origin); + } + for (const refused of [ + 'https://ask.example.com', + // Plain HTTP would expose the keys in transit. + 'http://ask.apimatic.io', + 'https://ask.apimatic.io.example.com', + 'https://evilapimatic.io', + 'https://ask.apimatic.io@evil.example', + 'http://localhost:@evil.example/', + 'http://localhost.example.com', + // `*.localhost` is resolved through DNS on some systems, so it may not be this machine. + 'http://ask.localhost' + ]) { + expect(errorsOf(at(refused)), refused).to.have.length(1); + } + }); + }); }); describe('pluginUrl', () => { @@ -486,7 +566,8 @@ describe('PortalConfig', () => { { label: 'Status', url: 'https://status.example.com', external: true }, { label: 'Changelog', url: '/changelog', external: false } ], - pageActions: false + pageActions: false, + copilot: null }); }); @@ -499,7 +580,8 @@ describe('PortalConfig', () => { favicon: null, colorMode: 'both', links: [], - pageActions: true + pageActions: true, + copilot: null }); }); diff --git a/test/types/portal/portal-copilot-entry.test.ts b/test/types/portal/portal-copilot-entry.test.ts new file mode 100644 index 000000000..96761cba1 --- /dev/null +++ b/test/types/portal/portal-copilot-entry.test.ts @@ -0,0 +1,36 @@ +import fs from 'fs'; +import path from 'path'; +import { expect } from 'chai'; +import { PortalConfig } from '../../../src/types/portal/portal-config'; +import { PortalCopilotEntry } from '../../../src/types/portal/portal-copilot-entry'; + +describe('PortalCopilotEntry', () => { + const entryFor = (block: object) => + PortalCopilotEntry.of( + PortalConfig.fromBlock(block, { name: 'Calc', description: null })._unsafeUnwrap() + ).toString(); + + const CHAT_IMPORT = "import('@/components/copilot')"; + + it('imports the Ask AI chat, lazily, for a portal that offers it', () => { + const entry = entryFor({ ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' } } }); + + expect(entry).to.contain(`const chat = ${CHAT_IMPORT};`); + expect(entry).to.contain('export const CopilotChat = lazy(() => chat);'); + }); + + // The build makes a chunk of any `import()` it finds; a type-only import leaves nothing behind. + it('names the chat only as a type for a portal without it, so its build carries none of the chat', () => { + const entry = entryFor({}); + + expect(entry).to.not.contain(CHAT_IMPORT); + expect(entry).to.contain("import type CopilotChatComponent from '@/components/copilot';"); + expect(entry).to.contain('export const CopilotChat: typeof CopilotChatComponent | null = null;'); + }); + + it('is what the template ships, for a portal without the chat', () => { + const shipped = fs.readFileSync(path.resolve('portal-template', 'src', 'lib', 'copilot-entry.ts'), 'utf8'); + + expect(shipped.replace(/\r\n/g, '\n')).to.equal(entryFor({})); + }); +}); diff --git a/test/types/portal/portal-stylesheet.test.ts b/test/types/portal/portal-stylesheet.test.ts index 9bbbc455b..89adb85af 100644 --- a/test/types/portal/portal-stylesheet.test.ts +++ b/test/types/portal/portal-stylesheet.test.ts @@ -3,7 +3,12 @@ import path from 'path'; import { expect } from 'chai'; import { compile } from 'tailwindcss'; import { PortalConfig } from '../../../src/types/portal/portal-config'; -import { DARK_SELECTOR, LIGHT_SELECTOR, PortalStylesheet } from '../../../src/types/portal/portal-stylesheet'; +import { + CHAT_PRESET, + DARK_SELECTOR, + LIGHT_SELECTOR, + PortalStylesheet +} from '../../../src/types/portal/portal-stylesheet'; describe('PortalStylesheet', () => { const stylesheetFor = (block: object) => @@ -49,6 +54,16 @@ describe('PortalStylesheet', () => { // A bare `:root` has the specificity of the theme's `.dark` block and comes after it, so a // colour meant for light mode would win in dark mode as well. + // The chat's classes would otherwise come with every portal's stylesheet, chat or not. + it("imports the Ask AI chat's preset only for a portal that offers the chat, before any rule", () => { + const copilot = { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' }; + + expect(stylesheetFor({})).to.not.contain(CHAT_PRESET); + const css = stylesheetFor({ ai: { copilot }, brand: { colors: { primary: '#1d4ed8' } } }); + expect(css).to.contain(CHAT_PRESET); + expect(css.indexOf(CHAT_PRESET)).to.be.below(css.indexOf(`${LIGHT_SELECTOR} {`)); + }); + it('scopes the light rule away from dark mode', () => { const css = stylesheetFor({ brand: { colors: { primary: '#1d4ed8' } } }); @@ -66,7 +81,7 @@ describe('PortalStylesheet', () => { * `app.css` also imports set no colour outside `@theme`, and one of them loads a plugin, so * they are left out. */ - const compiled = async (theme: string): Promise => { + const compiler = async (theme: string) => { // As Node looks a package up, from the real directory of the file importing it: under // pnpm a package's own dependencies sit beside it, not at the top of `node_modules`. const resolvePackage = (id: string, base: string): string => { @@ -85,8 +100,19 @@ describe('PortalStylesheet', () => { return { path: file, base: path.dirname(file), content: fs.readFileSync(file, 'utf8') }; }; const app = "@import 'tailwindcss';\n@import 'fumadocs-ui/css/neutral.css';\n@import './theme.css';\n"; - return (await compile(app, { base: styles, loadStylesheet })).build([]); + return await compile(app, { base: styles, loadStylesheet }); }; + const compiled = async (theme: string): Promise => (await compiler(theme)).build([]); + + it("scans the Ask AI chat's code for its classes only for a portal that offers the chat", async () => { + const chatSources = async (block: object) => + (await compiler(stylesheetFor(block))).sources.filter(({ base }) => base.includes('ai-chat')); + + expect(await chatSources({})).to.be.empty; + expect( + await chatSources({ ai: { copilot: { key: 'pk_test', apiUrl: 'https://ask.apimatic.io' } } }) + ).to.have.length(1); + }); it("keeps each mode's primary, after the neutral theme's own rules", async () => { const output = await compiled(