@chipmobilesdk/rn-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +134 -0
- package/package.json +42 -0
- package/src/config/behaviorConfig.ts +183 -0
- package/src/core/aiClient.ts +230 -0
- package/src/diagnostics/errors.ts +71 -0
- package/src/diagnostics/messageKeys.ts +27 -0
- package/src/index.ts +34 -0
- package/src/react/hooks.ts +113 -0
- package/src/transport/sseClient.ts +106 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog — @chipmobilesdk/rn-ai
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-07-05
|
|
4
|
+
|
|
5
|
+
Initial release (feature `005-azure-ai-gateway`).
|
|
6
|
+
|
|
7
|
+
- `createAiClient` — config-driven gateway client: cache-first app behavior
|
|
8
|
+
config (ETag/TTL), token acquisition via `@chipmobilesdk/rn-auth`
|
|
9
|
+
(`resourceKey`), SSE streaming over zero-dependency XHR transport.
|
|
10
|
+
- Multimodal chat requests: `text`, `image` (base64), `audio` (wav/mp3).
|
|
11
|
+
- Silent endpoint re-route on designated `404`/`410` signals (max 1 retry);
|
|
12
|
+
one automatic fresh-token retry on `401`; no automatic retry on `429`.
|
|
13
|
+
- `useAiChat` (idle/loading/streaming/done/error/rate-limited + accumulated
|
|
14
|
+
text + `retryAt`) and `useAiQuota` hooks.
|
|
15
|
+
- Typed `AiError` codes and `ai.*` message keys with English defaults.
|
|
16
|
+
- Per-app kill switch honored (`features.aiEnabled`).
|
|
17
|
+
|
|
18
|
+
Adoption: additive — no changes required in existing apps. Requires an
|
|
19
|
+
`ai-gateway` resource entry in the rn-auth config and a registered app on the
|
|
20
|
+
gateway platform.
|
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# @chipmobilesdk/rn-ai
|
|
2
|
+
|
|
3
|
+
AI gateway client for ChipMobileSdk-family React Native apps. Streams
|
|
4
|
+
multimodal (text + image + audio in, text out) AI responses through the
|
|
5
|
+
platform gateway with per-user rate limiting — apps never talk to Azure
|
|
6
|
+
OpenAI directly and hold no AI credentials.
|
|
7
|
+
|
|
8
|
+
> **License:** `UNLICENSED`. Published publicly for use by the owner's
|
|
9
|
+
> applications; no open-source license is granted.
|
|
10
|
+
|
|
11
|
+
- **Version**: `0.1.0`
|
|
12
|
+
- **Entrypoint**: `@chipmobilesdk/rn-ai`
|
|
13
|
+
- **Native code**: none
|
|
14
|
+
|
|
15
|
+
- Contract: [rn-ai-public-api](../../specs/005-azure-ai-gateway/contracts/rn-ai-public-api.md)
|
|
16
|
+
- Gateway API: [gateway-api](../../specs/005-azure-ai-gateway/contracts/gateway-api.md)
|
|
17
|
+
- Behavior config: [app-behavior-config](../../specs/005-azure-ai-gateway/contracts/app-behavior-config.md)
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @chipmobilesdk/rn-ai @chipmobilesdk/rn-auth
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Peers: `react >=19`, `react-native >=0.85`, `@chipmobilesdk/rn-auth >=0.2.0`.
|
|
26
|
+
**Zero runtime dependencies**; streaming uses React Native's standard
|
|
27
|
+
`XMLHttpRequest` (no native code, Hermes-compatible, iOS + Android).
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
1. Add the gateway resource to your rn-auth config:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
resources: [
|
|
35
|
+
{ key: 'ai-gateway', scopes: ['api://aiapp-gateway/AI.Access'] },
|
|
36
|
+
]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
2. Create the client (one per app):
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createAiClient } from '@chipmobilesdk/rn-ai';
|
|
43
|
+
import { auth } from './auth/setup';
|
|
44
|
+
|
|
45
|
+
export const aiClient = createAiClient({
|
|
46
|
+
appId: 'scan-food',
|
|
47
|
+
configUrl: 'https://<config-host>/appconfig/config/scan-food.json',
|
|
48
|
+
resourceKey: 'ai-gateway',
|
|
49
|
+
auth,
|
|
50
|
+
// storage: inject a persistent AiConfigCache for offline launches
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
3. Use the hooks:
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { useAiChat, useAiQuota } from '@chipmobilesdk/rn-ai';
|
|
58
|
+
import { aiClient } from './ai/setup';
|
|
59
|
+
|
|
60
|
+
const { send, cancel, status, text, error, retryAt } = useAiChat(aiClient);
|
|
61
|
+
const { quota } = useAiQuota(aiClient);
|
|
62
|
+
|
|
63
|
+
send({
|
|
64
|
+
messages: [{
|
|
65
|
+
role: 'user',
|
|
66
|
+
content: [
|
|
67
|
+
{ type: 'text', text: 'What dish is this? Estimate calories.' },
|
|
68
|
+
{ type: 'image', mimeType: 'image/jpeg', dataBase64: photoBase64 },
|
|
69
|
+
],
|
|
70
|
+
}],
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Runtime behavior
|
|
75
|
+
|
|
76
|
+
- **Config-driven endpoint**: resolved lazily on the first quota or chat
|
|
77
|
+
request. A fresh cache entry is used immediately; a stale entry is used
|
|
78
|
+
immediately and revalidated in the background with its ETag. With no cache,
|
|
79
|
+
the first operation waits for the config request. Designated `404`/`410`
|
|
80
|
+
responses invalidate config and retry against the refreshed endpoint.
|
|
81
|
+
- **Auth**: acquires tokens via rn-auth (`requestAccessToken('ai-gateway')`);
|
|
82
|
+
on `401`, reacquires a token and retries once.
|
|
83
|
+
- **Rate limits**: `429` surfaces as `status: 'rate-limited'` with `retryAt`;
|
|
84
|
+
the SDK never hammers and never enforces limits locally (display only).
|
|
85
|
+
- **Typed errors** (`AiError.code`): `auth-invalid`, `not-entitled`,
|
|
86
|
+
`user-blocked`, `app-unknown`, `rate-limited`, `upstream-busy`,
|
|
87
|
+
`payload-too-large`, `modality-not-allowed`, `config-unavailable`,
|
|
88
|
+
`upstream-error`, `internal`.
|
|
89
|
+
- **Copy keys**: `ai.*` defaults in `aiMessages` — map through your i18n.
|
|
90
|
+
|
|
91
|
+
## Public API
|
|
92
|
+
|
|
93
|
+
Everything is exported from `@chipmobilesdk/rn-ai`; internal `config/`,
|
|
94
|
+
`transport/`, `core/`, `react/`, and `diagnostics/` paths are not public.
|
|
95
|
+
|
|
96
|
+
- Client: `createAiClient`, `AiClient`, `AiClientConfig`, `TokenProvider`
|
|
97
|
+
- Chat: `ChatRequest`, `ChatMessage`, `ContentPart`, `ChatStreamHandlers`,
|
|
98
|
+
`ChatHandle`, `Usage`
|
|
99
|
+
- Behavior and cache: `AppBehaviorConfig`, `AiBehaviorState`, `BehaviorState`,
|
|
100
|
+
`AiConfigCache`, `QuotaSnapshot`
|
|
101
|
+
- React: `useAiChat`, `useAiQuota`, `AiChatStatus`, `UseAiChatResult`,
|
|
102
|
+
`UseAiQuotaResult`
|
|
103
|
+
- Diagnostics and copy: `AiError`, `AiErrorCode`, `isAiError`, `aiMessages`,
|
|
104
|
+
`aiMessage`, `AiMessageKey`
|
|
105
|
+
|
|
106
|
+
## App responsibilities
|
|
107
|
+
|
|
108
|
+
- **AI disclosure (store policy)**: user text/images/audio are processed by
|
|
109
|
+
the platform's AI service. Update your privacy policy, Apple App Privacy,
|
|
110
|
+
and Google Play Data Safety before shipping, and disclose AI processing
|
|
111
|
+
in-app (POL-005/006 in the feature spec).
|
|
112
|
+
- Camera/microphone permissions for capture UIs are yours; the SDK only
|
|
113
|
+
accepts already-captured data and adds no permissions.
|
|
114
|
+
- Respect `payload-too-large` (default cap 10 MB/request) by resizing images
|
|
115
|
+
and bounding audio length.
|
|
116
|
+
|
|
117
|
+
## Compatibility
|
|
118
|
+
|
|
119
|
+
React ≥19 · React Native ≥0.85 (Hermes) · iOS + Android · Node ≥22.11 for
|
|
120
|
+
tooling.
|
|
121
|
+
|
|
122
|
+
The repository contains `src/screens/AiDemoScreen.tsx` and its component test.
|
|
123
|
+
The current demo sidebar does not expose that screen yet, so it must be wired
|
|
124
|
+
into `ScreenKey`, `Sidebar`, and `ActiveScreen` before it is reachable in-app.
|
|
125
|
+
|
|
126
|
+
## Validation
|
|
127
|
+
|
|
128
|
+
Run from the repository root:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
npm run typecheck:ai
|
|
132
|
+
npm run pack:ai
|
|
133
|
+
npm run validate:ai
|
|
134
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chipmobilesdk/rn-ai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"react-native": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src/index.ts",
|
|
17
|
+
"src/config",
|
|
18
|
+
"src/transport",
|
|
19
|
+
"src/core",
|
|
20
|
+
"src/react",
|
|
21
|
+
"src/diagnostics",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"scripts": {
|
|
27
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
28
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@chipmobilesdk/rn-auth": ">=0.2.0",
|
|
32
|
+
"react": ">=19",
|
|
33
|
+
"react-native": ">=0.85"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.11.0"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"registry": "https://registry.npmjs.org/"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { AiError } from '../diagnostics/errors';
|
|
2
|
+
|
|
3
|
+
/** App behavior configuration (contracts/app-behavior-config.md). */
|
|
4
|
+
export interface AppBehaviorConfig {
|
|
5
|
+
schemaVersion: number;
|
|
6
|
+
appId: string;
|
|
7
|
+
configVersion: number;
|
|
8
|
+
refreshTtlSeconds: number;
|
|
9
|
+
api: { baseUrl: string; scopes: string[]; supportsStreaming: boolean };
|
|
10
|
+
limitsDisplay?: { requestsPerDay?: number; tokensPerDay?: number };
|
|
11
|
+
recovery: {
|
|
12
|
+
reloadOnHttpStatus: number[];
|
|
13
|
+
maxSilentRetries: number;
|
|
14
|
+
retryBackoffMs: number[];
|
|
15
|
+
};
|
|
16
|
+
features: { aiEnabled: boolean };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Persistence port; default is in-memory (inject a durable store for offline launch). */
|
|
20
|
+
export interface AiConfigCache {
|
|
21
|
+
get(key: string): Promise<string | null>;
|
|
22
|
+
set(key: string, value: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class MemoryConfigCache implements AiConfigCache {
|
|
26
|
+
private map = new Map<string, string>();
|
|
27
|
+
async get(key: string): Promise<string | null> {
|
|
28
|
+
return this.map.get(key) ?? null;
|
|
29
|
+
}
|
|
30
|
+
async set(key: string, value: string): Promise<void> {
|
|
31
|
+
this.map.set(key, value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type BehaviorState = 'unloaded' | 'ready-network' | 'ready-cache' | 'unavailable';
|
|
36
|
+
|
|
37
|
+
/** Parse + validate; unknown fields ignored (forward compatibility). */
|
|
38
|
+
export function parseBehaviorConfig(json: string): AppBehaviorConfig {
|
|
39
|
+
let raw: Record<string, unknown>;
|
|
40
|
+
try {
|
|
41
|
+
raw = JSON.parse(json) as Record<string, unknown>;
|
|
42
|
+
} catch {
|
|
43
|
+
throw new AiError('config-unavailable', 'behavior config is not valid JSON');
|
|
44
|
+
}
|
|
45
|
+
const api = raw.api as Partial<AppBehaviorConfig['api']> | undefined;
|
|
46
|
+
if (
|
|
47
|
+
typeof raw.appId !== 'string' ||
|
|
48
|
+
typeof api?.baseUrl !== 'string' ||
|
|
49
|
+
!Array.isArray(api.scopes)
|
|
50
|
+
) {
|
|
51
|
+
throw new AiError('config-unavailable', 'behavior config is missing required fields');
|
|
52
|
+
}
|
|
53
|
+
const recovery = (raw.recovery ?? {}) as Partial<AppBehaviorConfig['recovery']>;
|
|
54
|
+
const features = (raw.features ?? {}) as Partial<AppBehaviorConfig['features']>;
|
|
55
|
+
return {
|
|
56
|
+
schemaVersion: Number(raw.schemaVersion ?? 1),
|
|
57
|
+
appId: raw.appId,
|
|
58
|
+
configVersion: Number(raw.configVersion ?? 0),
|
|
59
|
+
refreshTtlSeconds: Number(raw.refreshTtlSeconds ?? 21600),
|
|
60
|
+
api: {
|
|
61
|
+
baseUrl: api.baseUrl.replace(/\/$/, ''),
|
|
62
|
+
scopes: api.scopes.filter((s): s is string => typeof s === 'string'),
|
|
63
|
+
supportsStreaming: api.supportsStreaming !== false,
|
|
64
|
+
},
|
|
65
|
+
limitsDisplay: raw.limitsDisplay as AppBehaviorConfig['limitsDisplay'],
|
|
66
|
+
recovery: {
|
|
67
|
+
reloadOnHttpStatus: recovery.reloadOnHttpStatus ?? [404, 410],
|
|
68
|
+
maxSilentRetries: recovery.maxSilentRetries ?? 1,
|
|
69
|
+
retryBackoffMs: recovery.retryBackoffMs ?? [500, 2000],
|
|
70
|
+
},
|
|
71
|
+
features: { aiEnabled: features.aiEnabled !== false },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface CacheEnvelope {
|
|
76
|
+
configJson: string;
|
|
77
|
+
etag?: string;
|
|
78
|
+
fetchedAt: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface BehaviorConfigDeps {
|
|
82
|
+
configUrl: string;
|
|
83
|
+
appId: string;
|
|
84
|
+
cache?: AiConfigCache;
|
|
85
|
+
fetchFn?: typeof fetch;
|
|
86
|
+
now?: () => number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Cache-first behavior config (FR-018): launch never blocks on the network;
|
|
91
|
+
* TTL revalidation uses ETag; designated errors force a reload.
|
|
92
|
+
*/
|
|
93
|
+
export class BehaviorConfigManager {
|
|
94
|
+
private cacheKey: string;
|
|
95
|
+
private cache: AiConfigCache;
|
|
96
|
+
private fetchFn: typeof fetch;
|
|
97
|
+
private now: () => number;
|
|
98
|
+
private current?: { config: AppBehaviorConfig; envelope: CacheEnvelope };
|
|
99
|
+
state: BehaviorState = 'unloaded';
|
|
100
|
+
|
|
101
|
+
constructor(private deps: BehaviorConfigDeps) {
|
|
102
|
+
this.cacheKey = `rn-ai:config:${deps.appId}`;
|
|
103
|
+
this.cache = deps.cache ?? new MemoryConfigCache();
|
|
104
|
+
this.fetchFn = deps.fetchFn ?? fetch;
|
|
105
|
+
this.now = deps.now ?? Date.now;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private isFresh(envelope: CacheEnvelope, config: AppBehaviorConfig): boolean {
|
|
109
|
+
return this.now() - envelope.fetchedAt < config.refreshTtlSeconds * 1000;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async loadFromCache(): Promise<void> {
|
|
113
|
+
if (this.current) return;
|
|
114
|
+
const stored = await this.cache.get(this.cacheKey).catch(() => null);
|
|
115
|
+
if (!stored) return;
|
|
116
|
+
try {
|
|
117
|
+
const envelope = JSON.parse(stored) as CacheEnvelope;
|
|
118
|
+
this.current = { config: parseBehaviorConfig(envelope.configJson), envelope };
|
|
119
|
+
this.state = 'ready-cache';
|
|
120
|
+
} catch {
|
|
121
|
+
// corrupted cache entries are ignored, not fatal
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async fetchNetwork(): Promise<AppBehaviorConfig | undefined> {
|
|
126
|
+
const headers: Record<string, string> = {};
|
|
127
|
+
if (this.current?.envelope.etag) {
|
|
128
|
+
headers['If-None-Match'] = this.current.envelope.etag;
|
|
129
|
+
}
|
|
130
|
+
const response = await this.fetchFn(this.deps.configUrl, { headers });
|
|
131
|
+
if (response.status === 304 && this.current) {
|
|
132
|
+
this.current.envelope.fetchedAt = this.now();
|
|
133
|
+
await this.persist(this.current.envelope);
|
|
134
|
+
this.state = 'ready-network';
|
|
135
|
+
return this.current.config;
|
|
136
|
+
}
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new AiError('config-unavailable', `config fetch failed: ${response.status}`);
|
|
139
|
+
}
|
|
140
|
+
const configJson = await response.text();
|
|
141
|
+
const config = parseBehaviorConfig(configJson);
|
|
142
|
+
const envelope: CacheEnvelope = {
|
|
143
|
+
configJson,
|
|
144
|
+
etag: response.headers.get('etag') ?? undefined,
|
|
145
|
+
fetchedAt: this.now(),
|
|
146
|
+
};
|
|
147
|
+
this.current = { config, envelope };
|
|
148
|
+
await this.persist(envelope);
|
|
149
|
+
this.state = 'ready-network';
|
|
150
|
+
return config;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async persist(envelope: CacheEnvelope): Promise<void> {
|
|
154
|
+
await this.cache.set(this.cacheKey, JSON.stringify(envelope)).catch(() => undefined);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Cache-first read. Fresh cache returns immediately; stale cache returns
|
|
159
|
+
* immediately too while a background revalidation runs; no cache at all
|
|
160
|
+
* falls through to the network (or `config-unavailable`).
|
|
161
|
+
*/
|
|
162
|
+
async getConfig(options: { forceReload?: boolean } = {}): Promise<AppBehaviorConfig> {
|
|
163
|
+
await this.loadFromCache();
|
|
164
|
+
if (!options.forceReload && this.current) {
|
|
165
|
+
if (!this.isFresh(this.current.envelope, this.current.config)) {
|
|
166
|
+
this.fetchNetwork().catch(() => undefined); // background revalidate
|
|
167
|
+
}
|
|
168
|
+
return this.current.config;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const fetched = await this.fetchNetwork();
|
|
172
|
+
if (fetched) return fetched;
|
|
173
|
+
} catch (e) {
|
|
174
|
+
if (this.current) return this.current.config; // stale beats broken
|
|
175
|
+
this.state = 'unavailable';
|
|
176
|
+
throw e instanceof AiError ? e : new AiError('config-unavailable', String(e));
|
|
177
|
+
}
|
|
178
|
+
// fetchNetwork only returns undefined when a 304 raced an empty cache.
|
|
179
|
+
if (this.current) return (this.current as { config: AppBehaviorConfig }).config;
|
|
180
|
+
this.state = 'unavailable';
|
|
181
|
+
throw new AiError('config-unavailable', 'no configuration available');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BehaviorConfigManager,
|
|
3
|
+
type AiConfigCache,
|
|
4
|
+
type AppBehaviorConfig,
|
|
5
|
+
type BehaviorState,
|
|
6
|
+
} from '../config/behaviorConfig';
|
|
7
|
+
import { AiError, errorFromHttp } from '../diagnostics/errors';
|
|
8
|
+
import { requestSse, type SseHandle } from '../transport/sseClient';
|
|
9
|
+
|
|
10
|
+
/** Minimal slice of the rn-auth `Auth` controller the client needs. */
|
|
11
|
+
export interface TokenProvider {
|
|
12
|
+
requestAccessToken(
|
|
13
|
+
resourceKey: string,
|
|
14
|
+
): Promise<{ status: string; token?: string }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ContentPart =
|
|
18
|
+
| { type: 'text'; text: string }
|
|
19
|
+
| { type: 'image'; mimeType: string; dataBase64: string }
|
|
20
|
+
| { type: 'audio'; format: 'wav' | 'mp3'; dataBase64: string };
|
|
21
|
+
|
|
22
|
+
export interface ChatMessage {
|
|
23
|
+
role: 'system' | 'user' | 'assistant';
|
|
24
|
+
content: ContentPart[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChatRequest {
|
|
28
|
+
messages: ChatMessage[];
|
|
29
|
+
options?: { temperature?: number; maxOutputTokens?: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Usage {
|
|
33
|
+
promptTokens: number;
|
|
34
|
+
completionTokens: number;
|
|
35
|
+
totalTokens: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface QuotaSnapshot {
|
|
39
|
+
minuteRemaining: number;
|
|
40
|
+
dayRemaining: number;
|
|
41
|
+
dayTokensRemaining: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ChatStreamHandlers {
|
|
45
|
+
onDelta(text: string): void;
|
|
46
|
+
onDone(result: { usage: Usage; quota?: QuotaSnapshot }): void;
|
|
47
|
+
onError(error: AiError): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ChatHandle {
|
|
51
|
+
cancel(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AiBehaviorState {
|
|
55
|
+
config: BehaviorState;
|
|
56
|
+
aiEnabled: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AiClientConfig {
|
|
60
|
+
appId: string;
|
|
61
|
+
configUrl: string;
|
|
62
|
+
auth: TokenProvider;
|
|
63
|
+
/** rn-auth resource key configured for the gateway API scope. */
|
|
64
|
+
resourceKey?: string;
|
|
65
|
+
storage?: AiConfigCache;
|
|
66
|
+
fetchFn?: typeof fetch;
|
|
67
|
+
/** Test seam for the SSE transport. */
|
|
68
|
+
transport?: typeof requestSse;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AiClient {
|
|
72
|
+
chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle;
|
|
73
|
+
getQuota(): Promise<QuotaSnapshot>;
|
|
74
|
+
getBehaviorState(): AiBehaviorState;
|
|
75
|
+
reloadConfig(): Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface StreamEvent {
|
|
79
|
+
type?: string;
|
|
80
|
+
text?: string;
|
|
81
|
+
usage?: Usage;
|
|
82
|
+
quota?: QuotaSnapshot;
|
|
83
|
+
code?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createAiClient(clientConfig: AiClientConfig): AiClient {
|
|
87
|
+
const resourceKey = clientConfig.resourceKey ?? 'ai-gateway';
|
|
88
|
+
const transport = clientConfig.transport ?? requestSse;
|
|
89
|
+
const fetchFn = clientConfig.fetchFn ?? fetch;
|
|
90
|
+
const configManager = new BehaviorConfigManager({
|
|
91
|
+
configUrl: clientConfig.configUrl,
|
|
92
|
+
appId: clientConfig.appId,
|
|
93
|
+
cache: clientConfig.storage,
|
|
94
|
+
fetchFn: clientConfig.fetchFn,
|
|
95
|
+
});
|
|
96
|
+
let lastConfig: AppBehaviorConfig | undefined;
|
|
97
|
+
|
|
98
|
+
async function acquire(): Promise<{ config: AppBehaviorConfig; token: string }> {
|
|
99
|
+
const config = await configManager.getConfig();
|
|
100
|
+
lastConfig = config;
|
|
101
|
+
if (!config.features.aiEnabled) {
|
|
102
|
+
throw new AiError('config-unavailable', 'AI is disabled for this app', {
|
|
103
|
+
recoverable: false,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const tokenResult = await clientConfig.auth.requestAccessToken(resourceKey);
|
|
107
|
+
if (tokenResult.status !== 'success' || !tokenResult.token) {
|
|
108
|
+
throw new AiError('auth-invalid', 'could not acquire an access token');
|
|
109
|
+
}
|
|
110
|
+
return { config, token: tokenResult.token };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const chatUrl = (config: AppBehaviorConfig) =>
|
|
114
|
+
`${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/chat`;
|
|
115
|
+
|
|
116
|
+
function chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle {
|
|
117
|
+
let cancelled = false;
|
|
118
|
+
let inner: SseHandle | undefined;
|
|
119
|
+
|
|
120
|
+
const attempt = async (state: { configReloads: number; authRetries: number }) => {
|
|
121
|
+
let config: AppBehaviorConfig;
|
|
122
|
+
let token: string;
|
|
123
|
+
try {
|
|
124
|
+
({ config, token } = await acquire());
|
|
125
|
+
} catch (e) {
|
|
126
|
+
handlers.onError(e instanceof AiError ? e : new AiError('internal', String(e)));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (cancelled) return;
|
|
130
|
+
|
|
131
|
+
let done: { usage: Usage; quota?: QuotaSnapshot } | undefined;
|
|
132
|
+
let streamErrorCode: string | undefined;
|
|
133
|
+
inner = transport(
|
|
134
|
+
{
|
|
135
|
+
url: chatUrl(config),
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {
|
|
138
|
+
Authorization: `Bearer ${token}`,
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify(request),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
onData: payload => {
|
|
145
|
+
if (payload === '[DONE]') return;
|
|
146
|
+
let event: StreamEvent;
|
|
147
|
+
try {
|
|
148
|
+
event = JSON.parse(payload) as StreamEvent;
|
|
149
|
+
} catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (event.type === 'delta' && typeof event.text === 'string') {
|
|
153
|
+
handlers.onDelta(event.text);
|
|
154
|
+
} else if (event.type === 'done' && event.usage) {
|
|
155
|
+
done = { usage: event.usage, quota: event.quota };
|
|
156
|
+
} else if (event.type === 'error') {
|
|
157
|
+
streamErrorCode = event.code ?? 'UPSTREAM_ERROR';
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
onEnd: () => {
|
|
161
|
+
if (cancelled) return;
|
|
162
|
+
if (streamErrorCode) {
|
|
163
|
+
handlers.onError(new AiError('upstream-error', 'the AI service failed mid-response'));
|
|
164
|
+
} else if (done) {
|
|
165
|
+
handlers.onDone(done);
|
|
166
|
+
} else {
|
|
167
|
+
handlers.onError(new AiError('internal', 'stream ended without completion'));
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
onError: (status, bodyText) => {
|
|
171
|
+
if (cancelled) return;
|
|
172
|
+
// Designated config-stale signals → silent reload + retry once (FR-018).
|
|
173
|
+
if (
|
|
174
|
+
config.recovery.reloadOnHttpStatus.includes(status) &&
|
|
175
|
+
state.configReloads < config.recovery.maxSilentRetries
|
|
176
|
+
) {
|
|
177
|
+
configManager
|
|
178
|
+
.getConfig({ forceReload: true })
|
|
179
|
+
.then(() => attempt({ ...state, configReloads: state.configReloads + 1 }))
|
|
180
|
+
.catch(e =>
|
|
181
|
+
handlers.onError(
|
|
182
|
+
e instanceof AiError ? e : new AiError('config-unavailable', String(e)),
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Expired server-side session → one fresh-token retry.
|
|
188
|
+
if (status === 401 && state.authRetries < 1) {
|
|
189
|
+
attempt({ ...state, authRetries: state.authRetries + 1 }).catch(() => undefined);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
handlers.onError(errorFromHttp(status, bodyText));
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
attempt({ configReloads: 0, authRetries: 0 }).catch(() => undefined);
|
|
199
|
+
return {
|
|
200
|
+
cancel() {
|
|
201
|
+
cancelled = true;
|
|
202
|
+
inner?.cancel();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function getQuota(): Promise<QuotaSnapshot> {
|
|
208
|
+
const { config, token } = await acquire();
|
|
209
|
+
const response = await fetchFn(
|
|
210
|
+
`${config.api.baseUrl}/api/v1/apps/${clientConfig.appId}/quota`,
|
|
211
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
212
|
+
);
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
throw errorFromHttp(response.status, await response.text());
|
|
215
|
+
}
|
|
216
|
+
return (await response.json()) as QuotaSnapshot;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
chat,
|
|
221
|
+
getQuota,
|
|
222
|
+
getBehaviorState: () => ({
|
|
223
|
+
config: configManager.state,
|
|
224
|
+
aiEnabled: lastConfig?.features.aiEnabled ?? true,
|
|
225
|
+
}),
|
|
226
|
+
reloadConfig: async () => {
|
|
227
|
+
lastConfig = await configManager.getConfig({ forceReload: true });
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Typed client errors (contracts/rn-ai-public-api.md). */
|
|
2
|
+
export type AiErrorCode =
|
|
3
|
+
| 'auth-invalid'
|
|
4
|
+
| 'not-entitled'
|
|
5
|
+
| 'user-blocked'
|
|
6
|
+
| 'app-unknown'
|
|
7
|
+
| 'rate-limited'
|
|
8
|
+
| 'upstream-busy'
|
|
9
|
+
| 'payload-too-large'
|
|
10
|
+
| 'modality-not-allowed'
|
|
11
|
+
| 'config-unavailable'
|
|
12
|
+
| 'upstream-error'
|
|
13
|
+
| 'internal';
|
|
14
|
+
|
|
15
|
+
export class AiError extends Error {
|
|
16
|
+
readonly code: AiErrorCode;
|
|
17
|
+
readonly retryAfterSeconds?: number;
|
|
18
|
+
readonly recoverable: boolean;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
code: AiErrorCode,
|
|
22
|
+
message: string,
|
|
23
|
+
options: { retryAfterSeconds?: number; recoverable?: boolean } = {},
|
|
24
|
+
) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'AiError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
29
|
+
this.recoverable =
|
|
30
|
+
options.recoverable ?? (code === 'rate-limited' || code === 'upstream-busy');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isAiError(value: unknown): value is AiError {
|
|
35
|
+
return value instanceof AiError;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const HTTP_CODE_MAP: Record<string, AiErrorCode> = {
|
|
39
|
+
AUTH_INVALID: 'auth-invalid',
|
|
40
|
+
NOT_ENTITLED: 'not-entitled',
|
|
41
|
+
USER_BLOCKED: 'user-blocked',
|
|
42
|
+
APP_UNKNOWN: 'app-unknown',
|
|
43
|
+
PAYLOAD_TOO_LARGE: 'payload-too-large',
|
|
44
|
+
MODALITY_NOT_ALLOWED: 'modality-not-allowed',
|
|
45
|
+
RATE_LIMITED: 'rate-limited',
|
|
46
|
+
UPSTREAM_BUSY: 'upstream-busy',
|
|
47
|
+
UPSTREAM_ERROR: 'upstream-error',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Map a gateway error response (status + JSON body) to a typed AiError. */
|
|
51
|
+
export function errorFromHttp(status: number, bodyText: string): AiError {
|
|
52
|
+
let code: AiErrorCode = status >= 500 ? 'upstream-error' : 'internal';
|
|
53
|
+
let message = `gateway responded ${status}`;
|
|
54
|
+
let retryAfterSeconds: number | undefined;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(bodyText) as {
|
|
57
|
+
error?: { code?: string; message?: string; retryAfterSeconds?: number };
|
|
58
|
+
};
|
|
59
|
+
if (parsed.error?.code && HTTP_CODE_MAP[parsed.error.code]) {
|
|
60
|
+
code = HTTP_CODE_MAP[parsed.error.code];
|
|
61
|
+
}
|
|
62
|
+
if (parsed.error?.message) message = parsed.error.message;
|
|
63
|
+
retryAfterSeconds = parsed.error?.retryAfterSeconds;
|
|
64
|
+
} catch {
|
|
65
|
+
if (status === 0) {
|
|
66
|
+
code = 'upstream-error';
|
|
67
|
+
message = 'network failure';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return new AiError(code, message, { retryAfterSeconds });
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing copy keys (UX-002/003), mirroring the rn-auth diagnostics
|
|
3
|
+
* pattern: apps may localize via rn-i18n; these are the defaults.
|
|
4
|
+
*/
|
|
5
|
+
export const aiMessages = {
|
|
6
|
+
'ai.state.loading': 'Thinking…',
|
|
7
|
+
'ai.state.streaming': 'Answering…',
|
|
8
|
+
'ai.input.placeholder': 'Ask about your scan…',
|
|
9
|
+
'ai.action.send': 'Send',
|
|
10
|
+
'ai.action.cancel': 'Cancel',
|
|
11
|
+
'ai.action.retry': 'Try again',
|
|
12
|
+
'ai.action.retryAt': 'You can try again at {time}',
|
|
13
|
+
'ai.quota.remaining': '{count} requests left today',
|
|
14
|
+
'ai.error.rateLimited': 'You have reached your usage limit.',
|
|
15
|
+
'ai.error.premiumRequired': 'AI features require a premium subscription for this app.',
|
|
16
|
+
'ai.error.blocked': 'AI access is suspended for this account.',
|
|
17
|
+
'ai.error.payloadTooLarge': 'That attachment is too large to send.',
|
|
18
|
+
'ai.error.modality': 'This kind of content is not supported here.',
|
|
19
|
+
'ai.error.busy': 'The AI service is busy. Please try again shortly.',
|
|
20
|
+
'ai.error.unavailable': 'AI features are currently unavailable.',
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type AiMessageKey = keyof typeof aiMessages;
|
|
24
|
+
|
|
25
|
+
export function aiMessage(key: AiMessageKey): string {
|
|
26
|
+
return aiMessages[key];
|
|
27
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @chipmobilesdk/rn-ai — AI gateway client for ChipMobileSdk-family React
|
|
3
|
+
* Native apps (feature 005). Single public entrypoint (LIB-003); modules
|
|
4
|
+
* under `config/`, `transport/`, `core/`, `react/`, and `diagnostics/` are
|
|
5
|
+
* private. Contract: specs/005-azure-ai-gateway/contracts/rn-ai-public-api.md
|
|
6
|
+
*/
|
|
7
|
+
export { createAiClient } from './core/aiClient';
|
|
8
|
+
export type {
|
|
9
|
+
AiClient,
|
|
10
|
+
AiClientConfig,
|
|
11
|
+
AiBehaviorState,
|
|
12
|
+
TokenProvider,
|
|
13
|
+
ChatRequest,
|
|
14
|
+
ChatMessage,
|
|
15
|
+
ContentPart,
|
|
16
|
+
ChatStreamHandlers,
|
|
17
|
+
ChatHandle,
|
|
18
|
+
Usage,
|
|
19
|
+
QuotaSnapshot,
|
|
20
|
+
} from './core/aiClient';
|
|
21
|
+
|
|
22
|
+
export type {
|
|
23
|
+
AppBehaviorConfig,
|
|
24
|
+
AiConfigCache,
|
|
25
|
+
BehaviorState,
|
|
26
|
+
} from './config/behaviorConfig';
|
|
27
|
+
|
|
28
|
+
export { useAiChat, useAiQuota } from './react/hooks';
|
|
29
|
+
export type { AiChatStatus, UseAiChatResult, UseAiQuotaResult } from './react/hooks';
|
|
30
|
+
|
|
31
|
+
export { AiError, isAiError } from './diagnostics/errors';
|
|
32
|
+
export type { AiErrorCode } from './diagnostics/errors';
|
|
33
|
+
export { aiMessages, aiMessage } from './diagnostics/messageKeys';
|
|
34
|
+
export type { AiMessageKey } from './diagnostics/messageKeys';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
AiClient,
|
|
4
|
+
ChatHandle,
|
|
5
|
+
ChatRequest,
|
|
6
|
+
QuotaSnapshot,
|
|
7
|
+
} from '../core/aiClient';
|
|
8
|
+
import type { AiError } from '../diagnostics/errors';
|
|
9
|
+
|
|
10
|
+
export type AiChatStatus =
|
|
11
|
+
| 'idle'
|
|
12
|
+
| 'loading'
|
|
13
|
+
| 'streaming'
|
|
14
|
+
| 'done'
|
|
15
|
+
| 'error'
|
|
16
|
+
| 'rate-limited';
|
|
17
|
+
|
|
18
|
+
export interface UseAiChatResult {
|
|
19
|
+
send(request: ChatRequest): void;
|
|
20
|
+
cancel(): void;
|
|
21
|
+
status: AiChatStatus;
|
|
22
|
+
/** Accumulated streamed text (appends incrementally — PERF-002). */
|
|
23
|
+
text: string;
|
|
24
|
+
error?: AiError;
|
|
25
|
+
retryAt?: Date;
|
|
26
|
+
quota?: QuotaSnapshot;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useAiChat(client: AiClient): UseAiChatResult {
|
|
30
|
+
const [status, setStatus] = useState<AiChatStatus>('idle');
|
|
31
|
+
const [text, setText] = useState('');
|
|
32
|
+
const [error, setError] = useState<AiError | undefined>();
|
|
33
|
+
const [retryAt, setRetryAt] = useState<Date | undefined>();
|
|
34
|
+
const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
|
|
35
|
+
const handleRef = useRef<ChatHandle | null>(null);
|
|
36
|
+
|
|
37
|
+
const send = useCallback(
|
|
38
|
+
(request: ChatRequest) => {
|
|
39
|
+
handleRef.current?.cancel();
|
|
40
|
+
setText('');
|
|
41
|
+
setError(undefined);
|
|
42
|
+
setRetryAt(undefined);
|
|
43
|
+
setStatus('loading');
|
|
44
|
+
handleRef.current = client.chat(request, {
|
|
45
|
+
onDelta: delta => {
|
|
46
|
+
setStatus('streaming');
|
|
47
|
+
setText(previous => previous + delta);
|
|
48
|
+
},
|
|
49
|
+
onDone: result => {
|
|
50
|
+
if (result.quota) setQuota(result.quota);
|
|
51
|
+
setStatus('done');
|
|
52
|
+
},
|
|
53
|
+
onError: aiError => {
|
|
54
|
+
setError(aiError);
|
|
55
|
+
if (aiError.code === 'rate-limited') {
|
|
56
|
+
setStatus('rate-limited');
|
|
57
|
+
if (aiError.retryAfterSeconds !== undefined) {
|
|
58
|
+
setRetryAt(new Date(Date.now() + aiError.retryAfterSeconds * 1000));
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
setStatus('error');
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
[client],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const cancel = useCallback(() => {
|
|
70
|
+
handleRef.current?.cancel();
|
|
71
|
+
setStatus('idle');
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
useEffect(() => () => handleRef.current?.cancel(), []);
|
|
75
|
+
|
|
76
|
+
return { send, cancel, status, text, error, retryAt, quota };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface UseAiQuotaResult {
|
|
80
|
+
quota?: QuotaSnapshot;
|
|
81
|
+
status: 'loading' | 'ready' | 'error';
|
|
82
|
+
refresh(): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useAiQuota(client: AiClient): UseAiQuotaResult {
|
|
86
|
+
const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
|
|
87
|
+
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
88
|
+
const alive = useRef(true);
|
|
89
|
+
|
|
90
|
+
const refresh = useCallback(() => {
|
|
91
|
+
setStatus('loading');
|
|
92
|
+
client
|
|
93
|
+
.getQuota()
|
|
94
|
+
.then(snapshot => {
|
|
95
|
+
if (!alive.current) return;
|
|
96
|
+
setQuota(snapshot);
|
|
97
|
+
setStatus('ready');
|
|
98
|
+
})
|
|
99
|
+
.catch(() => {
|
|
100
|
+
if (alive.current) setStatus('error');
|
|
101
|
+
});
|
|
102
|
+
}, [client]);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
alive.current = true;
|
|
106
|
+
refresh();
|
|
107
|
+
return () => {
|
|
108
|
+
alive.current = false;
|
|
109
|
+
};
|
|
110
|
+
}, [refresh]);
|
|
111
|
+
|
|
112
|
+
return { quota, status, refresh };
|
|
113
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SSE reader over XMLHttpRequest (research R2).
|
|
3
|
+
*
|
|
4
|
+
* React Native's fetch does not reliably expose ReadableStream on Hermes, so
|
|
5
|
+
* we use XHR incremental text delivery (readyState LOADING + onprogress) —
|
|
6
|
+
* the same mechanism community SSE libraries use — with zero dependencies.
|
|
7
|
+
* The gateway never compresses SSE responses (contract), which keeps
|
|
8
|
+
* incremental delivery intact on both platforms.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SseRequest {
|
|
12
|
+
url: string;
|
|
13
|
+
method?: 'GET' | 'POST';
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
/** JSON string body for POST requests. */
|
|
16
|
+
body?: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SseHandlers {
|
|
21
|
+
/** One call per `data:` event payload (raw string, already de-framed). */
|
|
22
|
+
onData(payload: string): void;
|
|
23
|
+
/** Stream closed normally (transport level; `[DONE]` handling is the caller's). */
|
|
24
|
+
onEnd(): void;
|
|
25
|
+
/** Transport or HTTP error. `status` is 0 for network failures. */
|
|
26
|
+
onError(status: number, bodyText: string): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SseHandle {
|
|
30
|
+
cancel(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parse complete `data:` events out of `text` starting at `fromIndex`. */
|
|
34
|
+
export function drainSseEvents(
|
|
35
|
+
text: string,
|
|
36
|
+
fromIndex: number,
|
|
37
|
+
emit: (payload: string) => void,
|
|
38
|
+
): number {
|
|
39
|
+
let cursor = fromIndex;
|
|
40
|
+
for (;;) {
|
|
41
|
+
const sep = text.indexOf('\n\n', cursor);
|
|
42
|
+
if (sep === -1) return cursor; // incomplete event stays buffered
|
|
43
|
+
const rawEvent = text.slice(cursor, sep);
|
|
44
|
+
cursor = sep + 2;
|
|
45
|
+
const dataLines = rawEvent
|
|
46
|
+
.split('\n')
|
|
47
|
+
.filter(line => line.startsWith('data:'))
|
|
48
|
+
.map(line => line.slice(5).trimStart());
|
|
49
|
+
if (dataLines.length > 0) emit(dataLines.join('\n'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandle {
|
|
54
|
+
const xhr = new XMLHttpRequest();
|
|
55
|
+
let seen = 0;
|
|
56
|
+
let finished = false;
|
|
57
|
+
let cancelled = false;
|
|
58
|
+
|
|
59
|
+
const finish = (fn: () => void) => {
|
|
60
|
+
if (!finished && !cancelled) {
|
|
61
|
+
finished = true;
|
|
62
|
+
fn();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const pump = () => {
|
|
67
|
+
if (xhr.status !== 200) return; // error path handled on load/error
|
|
68
|
+
const text: string = xhr.responseText ?? '';
|
|
69
|
+
seen = drainSseEvents(text, seen, handlers.onData);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
xhr.open(request.method ?? 'POST', request.url, true);
|
|
73
|
+
for (const [k, v] of Object.entries(request.headers ?? {})) {
|
|
74
|
+
xhr.setRequestHeader(k, v);
|
|
75
|
+
}
|
|
76
|
+
xhr.setRequestHeader('Accept', 'text/event-stream');
|
|
77
|
+
if (request.timeoutMs) xhr.timeout = request.timeoutMs;
|
|
78
|
+
|
|
79
|
+
xhr.onprogress = pump;
|
|
80
|
+
xhr.onreadystatechange = () => {
|
|
81
|
+
if (xhr.readyState === 3) pump(); // LOADING: incremental text
|
|
82
|
+
};
|
|
83
|
+
xhr.onload = () => {
|
|
84
|
+
if (xhr.status === 200) {
|
|
85
|
+
pump();
|
|
86
|
+
finish(handlers.onEnd);
|
|
87
|
+
} else {
|
|
88
|
+
finish(() => handlers.onError(xhr.status, xhr.responseText ?? ''));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
xhr.onerror = () => finish(() => handlers.onError(0, 'network error'));
|
|
92
|
+
xhr.ontimeout = () => finish(() => handlers.onError(0, 'timeout'));
|
|
93
|
+
|
|
94
|
+
xhr.send(request.body);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
cancel() {
|
|
98
|
+
cancelled = true;
|
|
99
|
+
try {
|
|
100
|
+
xhr.abort();
|
|
101
|
+
} catch {
|
|
102
|
+
// aborting a settled XHR is a no-op
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|