Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
2471083
feat(event-handler): HTTP response streaming on Lambda and self-hosted
adrians5j Jul 30, 2026
82aae33
fix(event-handler): allow x-webiny-authorization in CORS preflight
adrians5j Jul 30, 2026
a77d210
fix(event-handler): make streamHandler survive into the deployed bundle
adrians5j Jul 30, 2026
aa54305
fix(event-handler): make Lambda response streaming work behind CloudF…
adrians5j Jul 31, 2026
ba22e8a
Merge branch 'next' into claude/http-response-streaming
adrians5j Aug 19, 2026
f55b0b0
wip: vnqua
adrians5j Aug 20, 2026
ff799f4
wip: spe6o
adrians5j Aug 20, 2026
5450a78
refactor: one public function per file in the AWS composition + serve…
adrians5j Aug 20, 2026
6555ebb
refactor: drop an unnecessary cast, document why the rspack entry is …
adrians5j Aug 21, 2026
bb8923a
feat(app-admin): AI mode in the command palette
adrians5j Aug 21, 2026
f699198
Merge branch 'next' into claude/ai-command-palette
adrians5j Aug 21, 2026
20adc37
refactor(ai-chat): split the feature from its HTTP transport
adrians5j Aug 21, 2026
89f212c
Merge branch 'next' into claude/http-response-streaming
adrians5j Aug 26, 2026
fb21adc
refactor: align composition naming with HandlerConfig, extract HttpSe…
adrians5j Aug 26, 2026
146dc05
refactor(event-handler-server): fold the response writers into server/
adrians5j Aug 26, 2026
238bcbd
refactor(event-handler-server): factory fn instead of an HttpServer c…
adrians5j Aug 26, 2026
cf798bc
refactor(app): ApiStreamClient always POSTs
adrians5j Aug 26, 2026
145d587
Merge remote-tracking branch 'origin/next' into claude/http-response-…
adrians5j Aug 26, 2026
365902b
refactor(ai-powerups): gate image enrichment at feature registration
adrians5j Aug 26, 2026
261fea7
refactor(ai-powerups): inject the stream route's dependencies
adrians5j Aug 27, 2026
797faa8
refactor(ai-powerups): hoist the reusable parts out of the stream route
adrians5j Aug 27, 2026
634b754
Merge remote-tracking branch 'origin/next' into claude/http-response-…
adrians5j Aug 27, 2026
69322c6
Merge branch 'next' into claude/http-response-streaming
adrians5j Aug 27, 2026
1955461
refactor(ai-powerups): stop nesting calls as arguments
adrians5j Aug 27, 2026
52c92da
Merge branch 'next' into claude/http-response-streaming
adrians5j Aug 27, 2026
a7f3bed
refactor: move toSseFrame into event-handler-core
adrians5j Aug 27, 2026
2eb47ea
fix(project-aws): correct a wrong cause in ApiCloudfront, raise the o…
adrians5j Aug 27, 2026
46690e3
docs(project-aws): clarify that originReadTimeout is an idle timeout
adrians5j Aug 27, 2026
56ab195
docs: name the CloudFront managed cache policy, add the WCP handoff p…
adrians5j Aug 28, 2026
38593c3
refactor(app): name the stream client's response type in the abstraction
adrians5j Aug 28, 2026
619760f
docs(project): record that the streamHandler append is backlogged, no…
adrians5j Aug 28, 2026
c1d5f90
refactor(app): move toError out of the stream client class
adrians5j Aug 28, 2026
6e02f50
refactor(event-handler-aws): extract drainStreamBody from the API Gat…
adrians5j Aug 28, 2026
745178e
refactor(app-file-manager): move ReenrichWithAi state into a presenter
adrians5j Aug 28, 2026
0970589
docs(code-style): add the presenter-owns-component-state rule
adrians5j Aug 28, 2026
5a39f7d
refactor(app-file-manager): reach the ReenrichWithAi presenter throug…
adrians5j Aug 28, 2026
c6af2b2
fix(event-handler-core): resolve routes inside route(), not at constr…
adrians5j Aug 28, 2026
02ccad1
Merge PR #5533 (HTTP response streaming) into claude/ai-command-palette
adrians5j Aug 29, 2026
2b14542
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Aug 31, 2026
0fc7c72
fix(ai-chat): green CI — dep versions, subpath export, router test
adrians5j Aug 31, 2026
7739302
chore: regenerate the webiny package after merging next
adrians5j Aug 31, 2026
80fbfc1
feat(app-admin-ui): reach AI mode with a second mod+k
adrians5j Aug 31, 2026
ea622cd
feat(ai-chat): stream the assistant's progress to the palette
adrians5j Aug 31, 2026
c2a6782
feat(ai-chat): take the model and key from AI Power-Ups providers
adrians5j Aug 31, 2026
3386cae
fix(ai-chat): serve the stream route under /stream/ so it can actuall…
adrians5j Aug 31, 2026
bd11958
fix(app): send the body's payload hash so OAC signing accepts it
adrians5j Sep 1, 2026
8b8155e
fix(event-handler-core): allow the payload-hash header through CORS p…
adrians5j Sep 1, 2026
af0e60b
Merge next, gate AI chat behind a flag, fix provider resolution
adrians5j Sep 1, 2026
ff25ca2
fix(api-core): let a project enable its own feature flags without a l…
adrians5j Sep 1, 2026
6a9a978
feat(project): declare the aiChat feature flag
adrians5j Sep 2, 2026
6c2e2bf
fix(feature-flags): include aiChat in the flags DTO
adrians5j Sep 2, 2026
d29acf1
Revert the aiChat feature flag
adrians5j Sep 2, 2026
2f0c851
feat(ai-chat): add createFolder, createTeam and listRoles; stop gatin…
adrians5j Sep 2, 2026
1bc401e
refactor(ai-chat): drop the approval signing secret
adrians5j Sep 2, 2026
44e8d91
fix(ai-chat): validate role and permission identifiers instead of tru…
adrians5j Sep 2, 2026
bcfa028
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 2, 2026
9b8637f
fix(ai-chat): send the accumulated history so an approved write can r…
adrians5j Sep 2, 2026
2d0d812
fix(ai-chat): never rewrite an inherited folder rule as a direct one
adrians5j Sep 2, 2026
d330d0a
fix(ai-chat): accumulate turn state across an approval resume
adrians5j Sep 2, 2026
390b215
feat(ai-chat): split folder permissions into grant and revoke
adrians5j Sep 3, 2026
04286da
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 3, 2026
4e3ec17
fix(api-event-handler-aws): register the task dispatcher in the strea…
adrians5j Sep 3, 2026
37babd4
docs(api-aco): correct what the folder access tools report back
adrians5j Sep 3, 2026
af5ef40
test(api-aco): cover the folder access AI tools
adrians5j Sep 4, 2026
78e5063
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 4, 2026
97fdb91
Merge branch 'next' into claude/ai-command-palette
adrians5j Sep 4, 2026
91060e6
refactor(api-aco,ai-chat): build optional keys with an if instead of …
adrians5j Sep 4, 2026
f34c4a5
refactor(ai-chat): fold the HTTP routes in, drop the ai-chat-http pac…
adrians5j Sep 4, 2026
afbd2f5
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 5, 2026
71ce0cb
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 7, 2026
a61ffee
refactor(api-headless-cms): query entries through the shared where/so…
adrians5j Sep 9, 2026
f6b9d08
fix(api-aco): say IDs, not slugs, in the listRoles tool description
adrians5j Sep 9, 2026
f2d2880
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 10, 2026
01643e1
fix(api-aco): stop createTeam demanding a description
adrians5j Sep 10, 2026
e910200
refactor(ai-chat): split the chat routes into definition and handler
adrians5j Sep 10, 2026
651fa44
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 10, 2026
63d1776
feat(api-aco): resolve role slugs to ids in createTeam instead of ref…
adrians5j Sep 10, 2026
b243360
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 11, 2026
212a320
refactor(api-aco): drop the role resolution createTeam no longer needs
adrians5j Sep 11, 2026
0906872
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 11, 2026
a36389e
docs(api-headless-cms): drop the MCP reference from the AI tools feature
adrians5j Sep 11, 2026
e3092cb
fix(ai-chat): report a tool that threw, instead of leaving its chip s…
adrians5j Sep 11, 2026
a176534
docs(app-admin): say plainly that the AI command is never executed
adrians5j Sep 12, 2026
dfad831
refactor(ai-chat): drop the buffered chat path, which nothing called
adrians5j Sep 14, 2026
38c13bc
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 14, 2026
1b2e4d9
docs(app-admin-ui): explain why the tool icon picker is not a component
adrians5j Sep 14, 2026
2d2ad9a
feat(admin-ui): add a Markdown component for block content
adrians5j Sep 14, 2026
35407a9
refactor(app-admin-ui): make AI a palette mode rather than a branch i…
adrians5j Sep 14, 2026
81ccda6
Merge remote-tracking branch 'origin/next' into claude/ai-command-pal…
adrians5j Sep 14, 2026
fab7c3e
refactor(app-admin-ui): rename PaletteModeChrome to PaletteModeAppear…
adrians5j Sep 14, 2026
343f5ce
refactor(app-admin): move the AI chat conversation into a presenter
adrians5j Sep 14, 2026
98aa434
refactor(app-admin-ui): build the palette mode as a plain object, not…
adrians5j Sep 14, 2026
bcc8c52
refactor(app-admin): give the palette presenter the query, and name t…
adrians5j Sep 14, 2026
f81fe2f
refactor(app-admin): let a command declare that it enters a palette mode
adrians5j Sep 14, 2026
4a0cbcc
refactor(app-admin): name the palette's mode members after the one mo…
adrians5j Sep 14, 2026
920753c
refactor(app-admin-ui): move the palette's global hotkeys into a hook
adrians5j Sep 14, 2026
95a23e8
refactor(app-admin-ui): drop the palette callbacks that only forwarde…
adrians5j Sep 14, 2026
c820e64
style(app-admin-ui): replace the palette's wrapped ternaries with ear…
adrians5j Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 22 additions & 21 deletions ai-context/code-style/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,27 @@ One rule per file. Each file states a single convention in `Do this / don't do t

Read every rule in this folder before writing or editing code.

| Rule | Summary |
| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
| [comments.md](./comments.md) | `//` single-line, `/* */` multi-line, end with a period. |
| [es-modules.md](./es-modules.md) | Use `import`/`export`, not CommonJS `require`. |
| [one-import-per-line.md](./one-import-per-line.md) | Only one named import per line. |
| [one-class-per-file.md](./one-class-per-file.md) | One file MUST contain only one class. |
| [no-backwards-compat.md](./no-backwards-compat.md) | Refactors ignore backwards compatibility unless the prompt says otherwise. |
| [no-console-in-backend.md](./no-console-in-backend.md) | No `console.*` in `api-*` code; use the DI `Logger`. |
| [no-inline-class-in-create-implementation.md](./no-inline-class-in-create-implementation.md) | Declare implementation classes separately with an `implements` clause. |
| [compose-css-class-names.md](./compose-css-class-names.md) | Compose class names with a `cn` helper, never `+` or template literals. |
| [no-inline-conditional-spreads.md](./no-inline-conditional-spreads.md) | Build objects with `if` statements, not inline conditional spreads/casts. |
| [no-nested-call-arguments.md](./no-nested-call-arguments.md) | Name each step; don't nest calls as arguments to other calls. |
| [one-public-function-per-file.md](./one-public-function-per-file.md) | One exported function per file; composition steps always get their own. |
| [prefer-type-annotation-over-cast.md](./prefer-type-annotation-over-cast.md) | Annotate the variable; never add parens just to cast an expression. |
| [no-container-as-service-locator.md](./no-container-as-service-locator.md) | Declare dependencies; don't inject a Container and resolve inside methods. |
| [no-stateless-private-methods.md](./no-stateless-private-methods.md) | A private method that never reads `this` becomes a module-level function. |
| [presenter-owns-component-state.md](./presenter-owns-component-state.md) | Non-trivial component state goes in a presenter, reached via `useFeature`. |
| [no-multiline-ternaries.md](./no-multiline-ternaries.md) | Ternaries stay on one line; wrapping or nested ones become `if` statements. |
| [prefer-provider-over-resolved-value.md](./prefer-provider-over-resolved-value.md) | Inject a provider with `get()`; never an abstraction holding another's result. |
| [check-the-layer-below-before-caching.md](./check-the-layer-below-before-caching.md) | Check whether the layer below already caches; memoizing providers go per request. |
| [without-authorization-only-for-private-models.md](./without-authorization-only-for-private-models.md) | `withoutAuthorization` is a no-op on private models; keep it where reads are filtered. |
| Rule | Summary |
| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| [comments.md](./comments.md) | `//` single-line, `/* */` multi-line, end with a period. |
| [es-modules.md](./es-modules.md) | Use `import`/`export`, not CommonJS `require`. |
| [one-import-per-line.md](./one-import-per-line.md) | Only one named import per line. |
| [one-class-per-file.md](./one-class-per-file.md) | One file MUST contain only one class; a route and its definition are the one exception. |
| [no-backwards-compat.md](./no-backwards-compat.md) | Refactors ignore backwards compatibility unless the prompt says otherwise. |
| [no-console-in-backend.md](./no-console-in-backend.md) | No `console.*` in `api-*` code; use the DI `Logger`. |
| [no-inline-class-in-create-implementation.md](./no-inline-class-in-create-implementation.md) | Declare implementation classes separately with an `implements` clause. |
| [compose-css-class-names.md](./compose-css-class-names.md) | Compose class names with a `cn` helper, never `+` or template literals. |
| [no-inline-conditional-spreads.md](./no-inline-conditional-spreads.md) | Build objects with `if` statements, not inline conditional spreads/casts. |
| [no-nested-call-arguments.md](./no-nested-call-arguments.md) | Name each step; don't nest calls as arguments to other calls. |
| [one-public-function-per-file.md](./one-public-function-per-file.md) | One exported function per file; composition steps always get their own. |
| [prefer-type-annotation-over-cast.md](./prefer-type-annotation-over-cast.md) | Annotate the variable; never add parens just to cast an expression. |
| [no-container-as-service-locator.md](./no-container-as-service-locator.md) | Declare dependencies; don't inject a Container and resolve inside methods. |
| [no-stateless-private-methods.md](./no-stateless-private-methods.md) | A private method that never reads `this` becomes a module-level function. |
| [presenter-owns-component-state.md](./presenter-owns-component-state.md) | Non-trivial component state goes in a presenter, reached via `useFeature`. |
| [no-multiline-ternaries.md](./no-multiline-ternaries.md) | Ternaries stay on one line; wrapping or nested ones become `if` statements. |
| [routes-delegate-to-use-cases.md](./routes-delegate-to-use-cases.md) | Routes parse and map; feature logic lives in a use case. |
| [prefer-provider-over-resolved-value.md](./prefer-provider-over-resolved-value.md) | Inject a provider with `get()`; never an abstraction holding another's result. |
| [check-the-layer-below-before-caching.md](./check-the-layer-below-before-caching.md) | Check whether the layer below already caches; memoizing providers go per request. |
| [without-authorization-only-for-private-models.md](./without-authorization-only-for-private-models.md) | `withoutAuthorization` is a no-op on private models; keep it where reads are filtered. |

When adding a new code-style rule, create a new `*.md` file here (one rule per file) and add it to the table above.
40 changes: 40 additions & 0 deletions ai-context/code-style/one-class-per-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,43 @@ export class Foo {}
export class Foo {}
export class Bar {} // move to Bar.ts
```

## Exception: an HTTP route and its definition

A route is two classes that exist only as a pair, and they belong in one file.

`HttpRouteDefinition` carries the method, the path and a reference to the handler. It takes no
dependencies, so the router can build every definition and match a request without constructing any
handler. `HttpRouteHandler` takes the dependencies and runs only once a request reaches that path.

Splitting them across two files buys nothing: neither is usable or testable without the other, and
the definition's only content is three fields naming the handler beside it. Keeping them together
also keeps the path next to the code that serves it.

```ts
// Good: AiChatStreamRoute.ts
class AiChatStreamRouteImpl implements HttpRouteHandler.Interface {
constructor(private readonly aiChat: AiChatUseCase.Interface) {}
async handle(request: IHttpRequest, response: IHttpResponseBuilder) {
/* ... */
}
}

export const AiChatStreamRoute = HttpRouteHandler.createImplementation({
implementation: AiChatStreamRouteImpl,
dependencies: [AiChatUseCase]
});

class AiChatStreamRouteDefinitionImpl implements HttpRouteDefinition.Interface {
readonly method = "POST";
readonly path = "/stream/ai/chat";
readonly handler = AiChatStreamRoute;
}

export const AiChatStreamRouteDefinition = HttpRouteDefinition.createImplementation({
implementation: AiChatStreamRouteDefinitionImpl,
dependencies: []
});
```

Only the definition is registered with the container. The router resolves the handler it names.
40 changes: 40 additions & 0 deletions ai-context/code-style/routes-delegate-to-use-cases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Routes Delegate To Use Cases

An `HttpRoute` is transport, not feature code. Its job is to parse the request, call one use case, and
map the outcome onto a status code. Business logic — auth checks, orchestration, calls to other
services — belongs in a use case behind its own abstraction, so it can be resolved, decorated and
tested without an HTTP request.

```ts
// Good
class CreateThingRouteImpl implements HttpRoute.Interface {
public readonly method = "POST";
public readonly path = "/things";

public constructor(private readonly createThing: CreateThingUseCase.Interface) {}

public async handle(request: IHttpRequest): Promise<IHttpResponse> {
const params = parseBody(request.body);
if (!params) {
return json(400, { error: "Invalid body." });
}
return json(200, await this.createThing.execute(params));
}
}
```

```ts
// Bad — the feature lives in the route, so nothing else can reuse or test it
class CreateThingRouteImpl implements HttpRoute.Interface {
public async handle(request: IHttpRequest): Promise<IHttpResponse> {
const identity = this.identityContext.getIdentity();
if (identity.isAnonymous()) {
return json(401, { error: "Authentication required." });
}
const validated = validate(request.body);
const created = await this.repository.create(validated);
await this.eventPublisher.publish(new ThingCreatedEvent(created));
return json(200, created);
}
}
```
53 changes: 53 additions & 0 deletions packages/admin-ui/src/Markdown/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from "react";
import { cn, makeDecoratable } from "~/utils.js";
import { Text, type TextProps } from "~/Text/index.js";
import { useAdminUi } from "~/AdminUiProvider/index.js";

/**
* Styling for compiled markdown BLOCKS.
*
* Tailwind's preflight strips list markers and paragraph margins, and the markdown compiler emits
* bare elements, so without this a list renders as unindented run-on text and a fenced code block as
* plain prose. Child selectors rather than a global stylesheet, so this reaches only markdown that
* was asked to be styled.
*
* Exported for a caller that needs the classes without this component, e.g. one already rendering
* compiled markdown inside its own element.
*/
export const markdownBlockClasses = [
"[&_p]:mb-xs [&_p:last-child]:mb-0",
"[&_ul]:mb-xs [&_ul]:list-disc [&_ul]:pl-lg",
"[&_ol]:mb-xs [&_ol]:list-decimal [&_ol]:pl-lg",
"[&_li]:mt-xxs",
"[&_strong]:font-semibold",
"[&_a]:underline",
"[&_code]:rounded [&_code]:bg-neutral-subtle [&_code]:px-xs [&_code]:font-mono",
"[&_pre]:mb-xs [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-neutral-subtle [&_pre]:p-sm"
].join(" ");

export interface MarkdownProps extends Pick<TextProps, "size" | "className"> {
children: React.ReactNode;
}

/**
* Renders markdown that contains block content: paragraphs, lists, fenced code.
*
* `Alert`, `Label` and the form-field descriptions call `compileMarkdown` directly and need none of
* this, because they render a sentence with a bold word or a link. Reach for this component when the
* source is long-form and its structure has to survive.
*
* Renders a `div`, since `Text` is a span by default and block markdown nested in one is invalid.
*/
const MarkdownBase = ({ children, size = "sm", className }: MarkdownProps) => {
const { compileMarkdown } = useAdminUi();

return (
<Text as="div" size={size} className={cn(markdownBlockClasses, className)}>
{compileMarkdown(children)}
</Text>
);
};

const Markdown = makeDecoratable("Markdown", MarkdownBase);

export { Markdown };
1 change: 1 addition & 0 deletions packages/admin-ui/src/Markdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Markdown.js";
1 change: 1 addition & 0 deletions packages/admin-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export * from "./Table/index.js";
export * from "./Tabs/index.js";
export * from "./Tag/index.js";
export * from "./Tags/index.js";
export * from "./Markdown/index.js";
export * from "./Text/index.js";
export * from "./Textarea/index.js";
export * from "./Toggle/index.js";
Expand Down
37 changes: 37 additions & 0 deletions packages/ai-chat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@webiny/ai-chat",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./index.js",
"./*": "./*"
},
"description": "AI assistant that answers questions about a Webiny project using its registered AI tools",
"keywords": [
"ai-chat:base"
],
"repository": {
"type": "git",
"url": "https://github.com/webiny/webiny-js.git",
"directory": "packages/ai-chat"
},
"license": "MIT",
"dependencies": {
"@webiny/api-core": "0.0.0",
"@webiny/event-handler-core": "0.0.0",
"@webiny/feature": "0.0.0",
"ai": "^7.0.73"
},
"devDependencies": {
"@webiny/build-tools": "0.0.0",
"@webiny/di": "^1.0.2",
"typescript": "^7.0.2",
"vitest": "^4.1.11"
},
"publishConfig": {
"access": "public"
},
"webiny": {
"publishFrom": "dist"
}
}
39 changes: 39 additions & 0 deletions packages/ai-chat/src/api/AiChatFeature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createFeature } from "@webiny/feature/api";
import { AiChatConfig } from "./abstractions.js";
import { AiChatUseCase } from "./AiChatUseCase.js";
import { EnvAiChatProvider } from "./EnvAiChatProvider.js";
import { AiChatStreamRouteDefinition } from "./AiChatStreamRoute.js";

/**
* Enough steps for the deepest expected chain: list models, describe one, query it, answer — plus room
* for a corrected retry after a rejected filter.
*/
const DEFAULT_MAX_STEPS = 12;

/**
* The AI chat feature: the use case, its configuration, and the HTTP route that reaches it.
*
* Routes live beside the use case rather than in a separate package, matching `AiImageEnrichment`.
* The assistant is only ever reached over HTTP, so a second package bought an indirection nobody
* used and one more manifest to keep in sync.
*/
export const AiChatFeature = createFeature({
name: "AiChat",
register: container => {
container.registerInstance(AiChatConfig, {
maxSteps: Number(process.env["WEBINY_API_AI_CHAT_MAX_STEPS"]) || DEFAULT_MAX_STEPS
});
/*
* Registered first so a later registration wins. AI Power-Ups overrides this with providers
* configured in the admin UI.
*/
container.register(EnvAiChatProvider);
container.register(AiChatUseCase);

/*
* Only the definition is registered: the router matches on it and builds the handler it
* names once a request actually hits the path.
*/
container.register(AiChatStreamRouteDefinition);
}
});
72 changes: 72 additions & 0 deletions packages/ai-chat/src/api/AiChatStreamRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { HttpRouteDefinition, HttpRouteHandler, toSseFrame } from "@webiny/event-handler-core";
import type { IHttpRequest, IHttpResponseBuilder } from "@webiny/event-handler-core";
import { AiChatUseCase } from "./abstractions.js";
import type { AiChatEvent } from "./events.js";
import { parseChatBody } from "./parseChatBody.js";

const BAD_REQUEST_MESSAGE =
"Provide either a non-empty `prompt` string or a `messages` array of model messages.";

/**
* Frame the feature's events as SSE records. The wire format comes from `event-handler-core`; which
* events exist, and what they carry, belongs to the feature, so the mapping lives here.
*/
async function* toSseFrames(events: AsyncIterable<AiChatEvent>): AsyncIterable<string> {
for await (const event of events) {
yield toSseFrame(event);
}
}

/**
* `POST /stream/ai/chat`, the same assistant as `/ai/chat` but reported as it works.
*
* A separate route rather than a flag on the buffered one: the two have genuinely different response
* contracts (one JSON object versus an event stream), so a client picks which it wants by the URL it
* calls, and the buffered route stays available for callers that cannot read a stream.
*
* Failures after the first byte cannot become a status code, since the response has already committed
* to 200, so the use case reports them as `error` events and the stream ends normally.
*/
class AiChatStreamRouteImpl implements HttpRouteHandler.Interface {
public constructor(private readonly aiChat: AiChatUseCase.Interface) {}

public async handle(
request: IHttpRequest,
response: IHttpResponseBuilder
): Promise<IHttpResponseBuilder> {
const parsed = parseChatBody(request.body);

if (!parsed) {
return response.status(400).json({ error: BAD_REQUEST_MESSAGE });
}

const events = this.aiChat.stream(parsed);
const frames = toSseFrames(events);

return response.sse(frames);
}
}

export const AiChatStreamRoute = HttpRouteHandler.createImplementation({
implementation: AiChatStreamRouteImpl,
dependencies: [AiChatUseCase]
});

class AiChatStreamRouteDefinitionImpl implements HttpRouteDefinition.Interface {
readonly name = "ai-chat-stream";
readonly method = "POST";
/*
* Under `/stream/*` because that prefix is what reaches a transport able to stream. On AWS,
* CloudFront routes `/stream/*` to the Lambda Function URL origin; everything else goes to API
* Gateway, which buffers the whole response no matter how the route produced it. A path outside
* this prefix still works, it just silently arrives all at once.
*/
readonly path = "/stream/ai/chat";
readonly handler = AiChatStreamRoute;
}

/** What the router matches on. Zero dependencies, so building it costs nothing. */
export const AiChatStreamRouteDefinition = HttpRouteDefinition.createImplementation({
implementation: AiChatStreamRouteDefinitionImpl,
dependencies: []
});
Loading
Loading