@chipmobilesdk/rn-ai 0.2.0 → 0.3.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 +158 -55
- package/README.md +159 -151
- package/package.json +39 -43
- package/skills/sdk-rn-ai/SKILL.md +56 -57
- package/src/config/behaviorConfig.ts +233 -200
- package/src/core/aiClient.ts +354 -281
- package/src/diagnostics/errors.ts +89 -75
- package/src/diagnostics/messageKeys.ts +63 -27
- package/src/index.ts +31 -35
- package/src/react/hooks.ts +141 -134
- package/src/transport/sseClient.ts +143 -143
- package/src/transport/timeouts.ts +53 -53
|
@@ -1,57 +1,56 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: sdk-rn-ai
|
|
3
|
-
description: "AI chat and quota via @chipmobilesdk/rn-ai: createAiClient, useAiChat, useAiQuota, streaming status, AI error codes. Use when adding an AI chat feature, showing quota, or handling AI request failures and cancellation."
|
|
4
|
-
metadata:
|
|
5
|
-
package: "@chipmobilesdk/rn-ai"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
- `
|
|
40
|
-
- `
|
|
41
|
-
- `
|
|
42
|
-
- `
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
| Version/peer constraints | `## Compatibility` |
|
|
1
|
+
---
|
|
2
|
+
name: sdk-rn-ai
|
|
3
|
+
description: "AI chat and quota via @chipmobilesdk/rn-ai: createAiClient, useAiChat, useAiQuota, streaming status, AI error codes. Use when adding an AI chat feature, showing quota, or handling AI request failures and cancellation."
|
|
4
|
+
metadata:
|
|
5
|
+
package: "@chipmobilesdk/rn-ai"
|
|
6
|
+
user-invocable: true
|
|
7
|
+
disable-model-invocation: false
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# @chipmobilesdk/rn-ai
|
|
11
|
+
|
|
12
|
+
Client and React hooks for AI chat backed by the gateway, including quota reporting
|
|
13
|
+
and a typed error model.
|
|
14
|
+
|
|
15
|
+
## Use this when
|
|
16
|
+
|
|
17
|
+
- Adding an AI chat screen or message composer
|
|
18
|
+
- Displaying remaining quota
|
|
19
|
+
- Handling AI request failure, cancellation, or streaming state
|
|
20
|
+
- Localising AI-related user messages
|
|
21
|
+
|
|
22
|
+
## The app owns more than you expect
|
|
23
|
+
|
|
24
|
+
This package deliberately does not handle everything. The README has an
|
|
25
|
+
`## App responsibilities` section listing what the consuming app must implement —
|
|
26
|
+
read it before assuming a behaviour is built in.
|
|
27
|
+
|
|
28
|
+
## Cancellation is required, not optional
|
|
29
|
+
|
|
30
|
+
`useAiChat` exposes streaming status. An in-flight request MUST be cancelled when the
|
|
31
|
+
screen unmounts, otherwise the response resolves against a dead component. Verify
|
|
32
|
+
teardown on every screen that uses it.
|
|
33
|
+
|
|
34
|
+
## Public API
|
|
35
|
+
|
|
36
|
+
Runtime:
|
|
37
|
+
|
|
38
|
+
- `createAiClient(...)` — transport-level client
|
|
39
|
+
- `useAiChat(...)` → `UseAiChatResult`, with `AiChatStatus` for state
|
|
40
|
+
- `useAiQuota(...)` → `UseAiQuotaResult`
|
|
41
|
+
- `AiError`, `isAiError` — narrow before reading `AiErrorCode`
|
|
42
|
+
- `aiMessages`, `aiMessage` — user-facing strings keyed by `AiMessageKey`
|
|
43
|
+
|
|
44
|
+
Use `aiMessage` for anything shown to the user. Never render `AiError.message` directly.
|
|
45
|
+
|
|
46
|
+
## Where the detail lives
|
|
47
|
+
|
|
48
|
+
Read one section of `node_modules/@chipmobilesdk/rn-ai/README.md`:
|
|
49
|
+
|
|
50
|
+
| Need | Section |
|
|
51
|
+
|---|---|
|
|
52
|
+
| First integration | `## Install`, `## Setup` |
|
|
53
|
+
| Streaming, retries, timeouts | `## Runtime behavior` |
|
|
54
|
+
| Full export list | `## Public API` |
|
|
55
|
+
| What the app must implement | `## App responsibilities` |
|
|
56
|
+
| Version/peer constraints | `## Compatibility` |
|
|
@@ -1,200 +1,233 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
private
|
|
107
|
-
private
|
|
108
|
-
private
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
private
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
this.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
1
|
+
import { createListenerSet, createSingleFlight } from '@chipmobilesdk/rn-core';
|
|
2
|
+
|
|
3
|
+
import { AiError } from '../diagnostics/errors';
|
|
4
|
+
import { DEFAULT_AI_TIMEOUTS, withTimeout } from '../transport/timeouts';
|
|
5
|
+
|
|
6
|
+
/** App behavior configuration (contracts/app-behavior-config.md). */
|
|
7
|
+
export interface AppBehaviorConfig {
|
|
8
|
+
schemaVersion: number;
|
|
9
|
+
appId: string;
|
|
10
|
+
configVersion: number;
|
|
11
|
+
refreshTtlSeconds: number;
|
|
12
|
+
api: { baseUrl: string; scopes: string[]; supportsStreaming: boolean };
|
|
13
|
+
limitsDisplay?: { requestsPerDay?: number; tokensPerDay?: number };
|
|
14
|
+
recovery: {
|
|
15
|
+
reloadOnHttpStatus: number[];
|
|
16
|
+
maxSilentRetries: number;
|
|
17
|
+
retryBackoffMs: number[];
|
|
18
|
+
};
|
|
19
|
+
features: { aiEnabled: boolean };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Persistence port; default is in-memory (inject a durable store for offline launch). */
|
|
23
|
+
export interface AiConfigCache {
|
|
24
|
+
get(key: string): Promise<string | null>;
|
|
25
|
+
set(key: string, value: string): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class MemoryConfigCache implements AiConfigCache {
|
|
29
|
+
private map = new Map<string, string>();
|
|
30
|
+
async get(key: string): Promise<string | null> {
|
|
31
|
+
return this.map.get(key) ?? null;
|
|
32
|
+
}
|
|
33
|
+
async set(key: string, value: string): Promise<void> {
|
|
34
|
+
this.map.set(key, value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type BehaviorState = 'unloaded' | 'ready-network' | 'ready-cache' | 'unavailable';
|
|
39
|
+
|
|
40
|
+
const numbers = (value: unknown): number[] | undefined =>
|
|
41
|
+
Array.isArray(value) ? value.filter((n): n is number => Number.isFinite(n)) : undefined;
|
|
42
|
+
|
|
43
|
+
/** Parse + validate; unknown fields ignored (forward compatibility). */
|
|
44
|
+
export function parseBehaviorConfig(json: string): AppBehaviorConfig {
|
|
45
|
+
let raw: Record<string, unknown>;
|
|
46
|
+
try {
|
|
47
|
+
raw = JSON.parse(json) as Record<string, unknown>;
|
|
48
|
+
} catch {
|
|
49
|
+
throw new AiError('config-unavailable', 'behavior config is not valid JSON');
|
|
50
|
+
}
|
|
51
|
+
const api = raw.api as Partial<AppBehaviorConfig['api']> | undefined;
|
|
52
|
+
if (
|
|
53
|
+
typeof raw.appId !== 'string' ||
|
|
54
|
+
typeof api?.baseUrl !== 'string' ||
|
|
55
|
+
!Array.isArray(api.scopes)
|
|
56
|
+
) {
|
|
57
|
+
throw new AiError('config-unavailable', 'behavior config is missing required fields');
|
|
58
|
+
}
|
|
59
|
+
const recovery = (raw.recovery ?? {}) as Partial<AppBehaviorConfig['recovery']>;
|
|
60
|
+
const features = (raw.features ?? {}) as Partial<AppBehaviorConfig['features']>;
|
|
61
|
+
return {
|
|
62
|
+
schemaVersion: Number(raw.schemaVersion ?? 1),
|
|
63
|
+
appId: raw.appId,
|
|
64
|
+
configVersion: Number(raw.configVersion ?? 0),
|
|
65
|
+
refreshTtlSeconds: Number(raw.refreshTtlSeconds ?? 21600),
|
|
66
|
+
api: {
|
|
67
|
+
baseUrl: api.baseUrl.replace(/\/$/, ''),
|
|
68
|
+
scopes: api.scopes.filter((s): s is string => typeof s === 'string'),
|
|
69
|
+
supportsStreaming: api.supportsStreaming !== false,
|
|
70
|
+
},
|
|
71
|
+
limitsDisplay: raw.limitsDisplay as AppBehaviorConfig['limitsDisplay'],
|
|
72
|
+
// Typed here, once: a remote document with `reloadOnHttpStatus: "404"` used to
|
|
73
|
+
// throw a TypeError inside the XHR error callback, where nothing caught it.
|
|
74
|
+
recovery: {
|
|
75
|
+
reloadOnHttpStatus: numbers(recovery.reloadOnHttpStatus) ?? [404, 410],
|
|
76
|
+
maxSilentRetries: Number.isFinite(recovery.maxSilentRetries)
|
|
77
|
+
? Number(recovery.maxSilentRetries)
|
|
78
|
+
: 1,
|
|
79
|
+
retryBackoffMs: numbers(recovery.retryBackoffMs) ?? [500, 2000],
|
|
80
|
+
},
|
|
81
|
+
features: { aiEnabled: features.aiEnabled !== false },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface CacheEnvelope {
|
|
86
|
+
configJson: string;
|
|
87
|
+
etag?: string;
|
|
88
|
+
fetchedAt: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface BehaviorConfigDeps {
|
|
92
|
+
configUrl: string;
|
|
93
|
+
appId: string;
|
|
94
|
+
cache?: AiConfigCache;
|
|
95
|
+
fetchFn?: typeof fetch;
|
|
96
|
+
now?: () => number;
|
|
97
|
+
/** Limit on one config fetch, body included. Defaults to 10 s. */
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Cache-first behavior config (FR-018): launch never blocks on the network;
|
|
103
|
+
* TTL revalidation uses ETag; designated errors force a reload.
|
|
104
|
+
*/
|
|
105
|
+
export class BehaviorConfigManager {
|
|
106
|
+
private cacheKey: string;
|
|
107
|
+
private cache: AiConfigCache;
|
|
108
|
+
private fetchFn: typeof fetch;
|
|
109
|
+
private now: () => number;
|
|
110
|
+
private current?: { config: AppBehaviorConfig; envelope: CacheEnvelope };
|
|
111
|
+
/**
|
|
112
|
+
* One cache read and one network fetch at a time (AI-08): a cold start makes a
|
|
113
|
+
* chat and a quota call together, and each used to fetch the config itself.
|
|
114
|
+
*/
|
|
115
|
+
private readonly cacheRead = createSingleFlight<void>();
|
|
116
|
+
private readonly networkFetch = createSingleFlight<AppBehaviorConfig | undefined>();
|
|
117
|
+
private currentState: BehaviorState = 'unloaded';
|
|
118
|
+
private readonly stateListeners = createListenerSet<[]>();
|
|
119
|
+
|
|
120
|
+
/** Where the config came from, or why there is none. */
|
|
121
|
+
get state(): BehaviorState {
|
|
122
|
+
return this.currentState;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Called whenever `state` changes (AI-09). */
|
|
126
|
+
subscribe(listener: () => void): () => void {
|
|
127
|
+
return this.stateListeners.add(listener);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private setState(next: BehaviorState): void {
|
|
131
|
+
if (next === this.currentState) return;
|
|
132
|
+
this.currentState = next;
|
|
133
|
+
this.stateListeners.notify();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
constructor(private deps: BehaviorConfigDeps) {
|
|
137
|
+
this.cacheKey = `rn-ai:config:${deps.appId}`;
|
|
138
|
+
this.cache = deps.cache ?? new MemoryConfigCache();
|
|
139
|
+
this.fetchFn = deps.fetchFn ?? fetch;
|
|
140
|
+
this.now = deps.now ?? Date.now;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private isFresh(envelope: CacheEnvelope, config: AppBehaviorConfig): boolean {
|
|
144
|
+
return this.now() - envelope.fetchedAt < config.refreshTtlSeconds * 1000;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private loadFromCache(): Promise<void> {
|
|
148
|
+
return this.current ? Promise.resolve() : this.cacheRead.run(() => this.readCache());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async readCache(): Promise<void> {
|
|
152
|
+
if (this.current) return;
|
|
153
|
+
const stored = await this.cache.get(this.cacheKey).catch(() => null);
|
|
154
|
+
if (!stored) return;
|
|
155
|
+
try {
|
|
156
|
+
const envelope = JSON.parse(stored) as CacheEnvelope;
|
|
157
|
+
this.current = { config: parseBehaviorConfig(envelope.configJson), envelope };
|
|
158
|
+
this.setState('ready-cache');
|
|
159
|
+
} catch {
|
|
160
|
+
// corrupted cache entries are ignored, not fatal
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private fetchNetwork(): Promise<AppBehaviorConfig | undefined> {
|
|
165
|
+
return this.networkFetch.run(() => this.fetchFromNetwork());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async fetchFromNetwork(): Promise<AppBehaviorConfig | undefined> {
|
|
169
|
+
const headers: Record<string, string> = {};
|
|
170
|
+
if (this.current?.envelope.etag) {
|
|
171
|
+
headers['If-None-Match'] = this.current.envelope.etag;
|
|
172
|
+
}
|
|
173
|
+
// Bounded: with no cache, a hung fetch used to block every chat and quota call.
|
|
174
|
+
const { response, configJson } = await withTimeout(
|
|
175
|
+
this.deps.timeoutMs ?? DEFAULT_AI_TIMEOUTS.configMs,
|
|
176
|
+
async signal => {
|
|
177
|
+
const res = await this.fetchFn(this.deps.configUrl, { headers, signal });
|
|
178
|
+
return { response: res, configJson: res.ok && res.status !== 304 ? await res.text() : '' };
|
|
179
|
+
},
|
|
180
|
+
() => new AiError('config-unavailable', 'config fetch timed out'),
|
|
181
|
+
);
|
|
182
|
+
if (response.status === 304 && this.current) {
|
|
183
|
+
this.current.envelope.fetchedAt = this.now();
|
|
184
|
+
await this.persist(this.current.envelope);
|
|
185
|
+
this.setState('ready-network');
|
|
186
|
+
return this.current.config;
|
|
187
|
+
}
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
throw new AiError('config-unavailable', `config fetch failed: ${response.status}`);
|
|
190
|
+
}
|
|
191
|
+
const config = parseBehaviorConfig(configJson);
|
|
192
|
+
const envelope: CacheEnvelope = {
|
|
193
|
+
configJson,
|
|
194
|
+
etag: response.headers.get('etag') ?? undefined,
|
|
195
|
+
fetchedAt: this.now(),
|
|
196
|
+
};
|
|
197
|
+
this.current = { config, envelope };
|
|
198
|
+
await this.persist(envelope);
|
|
199
|
+
this.setState('ready-network');
|
|
200
|
+
return config;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async persist(envelope: CacheEnvelope): Promise<void> {
|
|
204
|
+
await this.cache.set(this.cacheKey, JSON.stringify(envelope)).catch(() => undefined);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Cache-first read. Fresh cache returns immediately; stale cache returns
|
|
209
|
+
* immediately too while a background revalidation runs; no cache at all
|
|
210
|
+
* falls through to the network (or `config-unavailable`).
|
|
211
|
+
*/
|
|
212
|
+
async getConfig(options: { forceReload?: boolean } = {}): Promise<AppBehaviorConfig> {
|
|
213
|
+
await this.loadFromCache();
|
|
214
|
+
if (!options.forceReload && this.current) {
|
|
215
|
+
if (!this.isFresh(this.current.envelope, this.current.config)) {
|
|
216
|
+
this.fetchNetwork().catch(() => undefined); // background revalidate
|
|
217
|
+
}
|
|
218
|
+
return this.current.config;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const fetched = await this.fetchNetwork();
|
|
222
|
+
if (fetched) return fetched;
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if (this.current) return this.current.config; // stale beats broken
|
|
225
|
+
this.setState('unavailable');
|
|
226
|
+
throw e instanceof AiError ? e : new AiError('config-unavailable', String(e));
|
|
227
|
+
}
|
|
228
|
+
// fetchNetwork only returns undefined when a 304 raced an empty cache.
|
|
229
|
+
if (this.current) return (this.current as { config: AppBehaviorConfig }).config;
|
|
230
|
+
this.setState('unavailable');
|
|
231
|
+
throw new AiError('config-unavailable', 'no configuration available');
|
|
232
|
+
}
|
|
233
|
+
}
|