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