Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions .changeset/warm-pans-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@tailor-platform/app-shell": minor
---

Add a low-level AI Gateway client and a simple text-only chat hook for AppShell.

```tsx
import { createAIGatewayClient, useAIChat } from "@tailor-platform/app-shell";

const aiClient = createAIGatewayClient({ gatewayUri, authClient });
const { messages, sendMessage, stop } = useAIChat({ client: aiClient, model: "gpt-5-mini" });
```
1 change: 1 addition & 0 deletions .github/workflows/ci-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,6 @@ jobs:
env:
VITE_TAILOR_APP_URL: ${{ secrets.E2E_TAILOR_APP_URL }}
VITE_TAILOR_CLIENT_ID: ${{ secrets.E2E_TAILOR_CLIENT_ID }}
VITE_TAILOR_AI_GATEWAY_URL: ${{ secrets.E2E_TAILOR_AI_GATEWAY_URL }}
E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
116 changes: 116 additions & 0 deletions docs/api/create-ai-gateway-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
title: createAIGatewayClient
description: Create a low-level AI Gateway client that reuses AppShell authentication
---

# createAIGatewayClient

Creates a small AI Gateway transport client for text-only chat completions.

## Signature

```typescript
function createAIGatewayClient(config: {
gatewayUri: string;
authClient: AuthClient;
}): AIGatewayClient;
```

## Parameters

### `gatewayUri`

- **Type:** `string`
- **Required:** Yes
- **Description:** Base URL of the AI Gateway

### `authClient`

- **Type:** `AuthClient`
- **Required:** Yes
- **Description:** Auth client used for authenticated requests via `authClient.fetch(...)`

## Return Value

```typescript
interface AIGatewayClient {
streamChatCompletion(request: AIGatewayChatRequest): AsyncIterable<AIChatCompletionEvent>;
}
```

The iterable yields completion events:

- `text-delta` — append `event.text` to build the assistant response
- `done` — terminal event with an optional `finishReason`

## Related Types

```typescript
type AIGatewayChatMessage =
| {
role: "system" | "user";
content: string;
}
| {
role: "assistant";
content?: string;
};

interface AIGatewayChatRequest {
model: string;
messages: AIGatewayChatMessage[];
stream?: boolean;
signal?: AbortSignal;
}

type AIChatCompletionEvent =
| {
type: "text-delta";
text: string;
}
| {
type: "done";
finishReason?: string;
};
```

## Usage

```typescript
import { createAuthClient, createAIGatewayClient } from "@tailor-platform/app-shell";

const authClient = createAuthClient({
clientId: "your-client-id",
appUri: "https://xyz.erp.dev",
});

const aiClient = createAIGatewayClient({
gatewayUri: "https://your-ai-gateway.example.com",
authClient,
});

let text = "";
for await (const event of aiClient.streamChatCompletion({
model: "gpt-5-mini",
messages: [{ role: "user", content: "Hello" }],
})) {
if (event.type === "text-delta") {
text += event.text;
}
}

console.log(text);
```

## Notes

- `stream` defaults to `true`
- Pass `stream: false` when the endpoint returns a single JSON response instead of SSE
- `stream: false` still yields the same event shape: zero or one `text-delta`, then `done`
- The low-level API is intentionally narrow: text deltas plus completion metadata
- `request.signal` is passed through so callers can abort in-flight work

## Related

- [Authentication Concept](../concepts/authentication.md)
- [useAIChat](./use-ai-chat.md)
109 changes: 109 additions & 0 deletions docs/api/use-ai-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
---
title: useAIChat
description: Simple text-only chat hook for AI Gateway
---

# useAIChat

React hook for simple text-only chat on top of `createAIGatewayClient`.

## Signature

```typescript
const useAIChat: (config: { client: AIGatewayClient; model: string; stream?: boolean }) => {
messages: AIChatMessage[];
status: "ready" | "submitted" | "streaming" | "error";
error?: Error;
sendMessage: (message: string) => Promise<boolean>;
stop: () => void;
};
```

## Return Value

### `messages`

```typescript
interface AIChatMessage {
id: string;
role: "user" | "assistant";
content: string;
}
```

### `status`

- **Type:** `"ready" | "submitted" | "streaming" | "error"`
- **Description:** Current request state

### `error`

- **Type:** `Error | undefined`
- **Description:** Last request error, if any

### `sendMessage()`

- **Type:** `(message: string) => Promise<boolean>`
- **Description:** Appends a user message and streams the assistant response
- **Returns:** `true` when the request completes successfully, `false` when the call is ignored, stopped, or fails

### `stop()`

- **Type:** `() => void`
- **Description:** Aborts the current request if one is in progress

## Usage

```tsx
import { createAuthClient, createAIGatewayClient, useAIChat } from "@tailor-platform/app-shell";

const authClient = createAuthClient({
clientId: "your-client-id",
appUri: "https://xyz.erp.dev",
});

const aiClient = createAIGatewayClient({
gatewayUri: "https://your-ai-gateway.example.com",
authClient,
});

export function ChatScreen() {
const { messages, sendMessage, status, stop, error } = useAIChat({
client: aiClient,
model: "gpt-5-mini",
});

return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.role}: {message.content}
</div>
))}

<button
onClick={() => void sendMessage("Hello")}
disabled={status === "submitted" || status === "streaming"}
>
Send
</button>
<button onClick={stop} disabled={status !== "submitted" && status !== "streaming"}>
Stop
</button>
{error ? <div>{error.message}</div> : null}
</div>
);
}
```

## Notes

- `stream` defaults to `true`
- Pass `stream: false` when the endpoint returns a single JSON response instead of SSE
- The hook is intentionally text-only
- System prompts and custom history shaping should use the low-level client directly
- `stop()` keeps any already-streamed assistant text and ignores late chunks from the stopped request

## Related

- [createAIGatewayClient](./create-ai-gateway-client.md)
40 changes: 40 additions & 0 deletions docs/concepts/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,46 @@ function App() {
}
```

### Using `createAIGatewayClient` with AI Gateway

`createAIGatewayClient` reuses the same authenticated fetch path as the rest of AppShell. Requests go through `authClient.fetch`, so DPoP proof generation and token refresh stay in the auth layer.

```tsx
import { createAuthClient, createAIGatewayClient, useAIChat } from "@tailor-platform/app-shell";

const authClient = createAuthClient({
clientId: "your-client-id",
appUri: "https://xyz.erp.dev",
});

const aiClient = createAIGatewayClient({
gatewayUri: "https://your-ai-gateway.example.com",
authClient,
});

function ChatScreen() {
const { messages, sendMessage, status, stop } = useAIChat({
client: aiClient,
model: "gpt-5-mini",
});

return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.role}: {message.content}
</div>
))}

<button onClick={() => void sendMessage("Hello")}>Send</button>
<button onClick={stop} disabled={status !== "submitted" && status !== "streaming"}>
Stop
</button>
</div>
);
}
```

### `AuthClientConfig`

| Property | Type | Required | Description |
Expand Down
2 changes: 2 additions & 0 deletions e2e/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
VITE_TAILOR_APP_URL=
# OAuth2 public client ID
VITE_TAILOR_CLIENT_ID=
# AI Gateway URL (e.g., https://<gateway-domain>)
VITE_TAILOR_AI_GATEWAY_URL=

# E2E test user credentials
E2E_USER_EMAIL=e2e-test@example.com
Expand Down
25 changes: 15 additions & 10 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# E2E Tests for AuthProvider
# E2E Tests for AuthProvider and AI Gateway

Playwright-based E2E tests that verify the AuthProvider OAuth authentication flow against a real Tailor Platform workspace.
Playwright-based E2E tests that verify the AuthProvider OAuth authentication flow and a minimal AI Gateway smoke check against a real Tailor Platform workspace.

## Setup

Expand All @@ -11,7 +11,7 @@ cd e2e/backend
TAILOR_PLATFORM_WORKSPACE_ID=<your-workspace-id> pnpm deploy
```

After deploy, retrieve the app URL and client ID using `tailor-sdk`:
After deploy, retrieve the app URL, client ID, and AI Gateway URL using `tailor-sdk`:

```bash
# Get the app URL
Expand All @@ -21,6 +21,10 @@ npx tailor-sdk show --workspace-id <your-workspace-id> --json
# Get the OAuth2 client ID
npx tailor-sdk oauth2client list --workspace-id <your-workspace-id> --json
# → [{"clientId": "tpoc_...", ...}]

# Get the AI Gateway domain
npx tailor-sdk workspace app list --workspace-id <your-workspace-id> --json
# → find the entry named "e2e-ai-gateway" and use https://<domain>
```

### 2. Create a test user
Expand Down Expand Up @@ -50,7 +54,7 @@ mutation CreateUserProfile {
cp e2e/.env.example e2e/.env
```

Fill in `VITE_TAILOR_APP_URL` and `VITE_TAILOR_CLIENT_ID` (retrieved above). The test user credentials are pre-filled.
Fill in `VITE_TAILOR_APP_URL`, `VITE_TAILOR_CLIENT_ID`, and `VITE_TAILOR_AI_GATEWAY_URL` (retrieved above). The test user credentials are pre-filled.

### 4. Install dependencies & browsers

Expand All @@ -74,12 +78,13 @@ cd e2e && pnpm dev

## Test Scenarios

| Test | Description |
| ------------------- | ---------------------------------------------------------------- |
| Auth guard display | Verifies unauthenticated users see the login UI |
| Login flow | Full OAuth redirect → IDP login → callback → authenticated state |
| Logout | Verifies logout returns to auth guard |
| Session persistence | Confirms page reload maintains authentication |
| Test | Description |
| ------------------- | ------------------------------------------------------------------- |
| Auth guard display | Verifies unauthenticated users see the login UI |
| Login flow | Full OAuth redirect → IDP login → callback → authenticated state |
| Logout | Verifies logout returns to auth guard |
| Session persistence | Confirms page reload maintains authentication |
| AI Gateway smoke | Sends `PING` and checks the OpenAI-compatible reply contains `PONG` |

## Architecture

Expand Down
Loading
Loading