@chipmobilesdk/rn-ai 0.2.1 → 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 CHANGED
@@ -1,5 +1,92 @@
1
1
  # @chipmobilesdk/rn-ai
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - One diagnostic stream: install a sink with `installSdkSink` from
8
+ `@chipmobilesdk/rn-core` and it receives what every package observes (events,
9
+ failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
10
+ rn-core, so a listener that throws no longer stops the others nor fails the call that
11
+ notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
12
+
13
+ - rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
14
+ events are no longer deduplicated away; the dedupe memory is bounded. A throwing
15
+ status, consent or placement listener is reported as an `internal_error` event.
16
+ - rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
17
+ with a stable `code`); without it, warnings go to `console.warn` in development
18
+ builds only. The warn-once memory is bounded.
19
+ - rn-chart: now depends on rn-core for its diagnostics.
20
+ - rn-payment: status, entitlement and pending listeners report a throw to the logger
21
+ instead of swallowing it.
22
+
23
+ - The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
24
+ dependency: `isSdkError` recognises them alongside every other package's, and each
25
+ carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
26
+
27
+ - rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
28
+ deprecated alias.
29
+ - rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
30
+ e-mail addresses and UUIDs.
31
+ - rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
32
+ to its code.
33
+ - rn-ai: an error message quoted from the gateway is pattern-redacted.
34
+ - rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
35
+ - rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
36
+ `code: 'contrast'` (new `ThemeErrorCode` type).
37
+ - rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
38
+ `code: 'unit_conversion'` (new `I18nErrorCode` type).
39
+
40
+ - Timers, retries and bounds now come from `@chipmobilesdk/rn-core`, and the drift
41
+ between the copies is fixed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
42
+
43
+ - rn-ads: a retry policy out of bounds throws `retry_out_of_bounds`, as documented,
44
+ instead of `timeout_out_of_bounds`; a placement's `minRequestIntervalMs` out of
45
+ bounds throws `invalid_config`.
46
+ - rn-payment: backoff validates `initialDelayMs` (100 ms to 60 s, not above
47
+ `maxDelayMs`) and `factor` (1 to 10); `factor: 0` used to retry at once, forever.
48
+ Every timeout, backoff or interval out of bounds now throws the new
49
+ `PAYMENT_INVALID_CONFIG` instead of `PAYMENT_INVALID_CATALOG`, which is about
50
+ products.
51
+ - rn-ai: concurrent calls share one behavior-config request, so a cold start makes
52
+ one instead of one per call. A silent retry after a config reload waits
53
+ `recovery.retryBackoffMs`, which was parsed but never applied.
54
+
55
+ - Hooks read through `useSyncExternalStore` (rn-core's `useExternalStore`) and
56
+ re-render only when what they return changes. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
57
+
58
+ - rn-auth: `useProfile` and `useAccessTier` no longer re-render for a status change,
59
+ and the provider's context no longer changes on every transition. New
60
+ `useAuthSelector`. `auth.getState()` returns the same frozen object until the state
61
+ changes, instead of a copy per call.
62
+ - rn-payment: `useEntitlement` re-renders only when its own entitlement changes; the
63
+ engine keeps unchanged entitlement objects across syncs and does not notify for a
64
+ sync that changed nothing.
65
+ - rn-ads: `usePlacementState` and `useAdsStatus` read tear-free; `useAdsEvents` keeps
66
+ one subscription when given an inline listener.
67
+ - rn-i18n: `useTranslation` returns the same `t` until the locale changes, and
68
+ `useLocale`'s `setLocale` is stable.
69
+ - rn-ai: new `useAiBehaviorState`, with `client.subscribe` and a stable
70
+ `client.getBehaviorState()`.
71
+
72
+ - Every package's own copy is an rn-core message catalog that an app can translate. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
73
+
74
+ - rn-i18n: new `runtimeEntriesFromCatalog(catalog)` registers any SDK catalog in one
75
+ line, converting `{name}` variables to `{{name}}`.
76
+ - rn-ai: new `messageKeyForError(error)` gives the copy for every error code, and
77
+ `aiMessageSet`. The input placeholder default is neutral ("Type a message…"), and
78
+ two keys were added for errors that had none (`ai.error.signIn`, `ai.error.generic`).
79
+ - rn-auth: new `authMessages`, the catalog split into user and developer copy.
80
+ - rn-payment: new `paymentMessages`.
81
+ - rn-chart: summaries, announcements, adjustable-action labels, built-in state text
82
+ and legend hints come from `chartStrings`; pass `strings` to translate them. The
83
+ `accessibility.summaryFormatter` prop, declared before, now replaces the summary.
84
+
85
+ ### Patch Changes
86
+
87
+ - Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
88
+ - @chipmobilesdk/rn-core@0.1.0
89
+
3
90
  ## 0.2.1
4
91
 
5
92
  ### Patch Changes
package/README.md CHANGED
@@ -77,8 +77,12 @@ send({
77
77
  request. A fresh cache entry is used immediately; a stale entry is used
78
78
  immediately and revalidated in the background with its ETag. With no cache,
79
79
  the first operation waits for the config request, for at most `configMs`.
80
- Designated `404`/`410`
81
- responses invalidate config and retry against the refreshed endpoint.
80
+ Concurrent calls share one config request, so a cold start that asks for a
81
+ chat and the quota together fetches the config once. Designated `404`/`410`
82
+ responses invalidate config and retry against the refreshed endpoint, after
83
+ waiting `recovery.retryBackoffMs[n]` (the last step repeats, each capped at
84
+ 30 s), at most `recovery.maxSilentRetries` times. A chat cancelled during the
85
+ wait is not retried.
82
86
  - **Auth**: acquires tokens via rn-auth (`requestAccessToken('ai-gateway')`);
83
87
  on `401`, reacquires a token and retries once.
84
88
  - **Timeouts**: every network call is bounded. Override any of them with
@@ -86,14 +90,14 @@ send({
86
90
 
87
91
  | | Default | Fails with |
88
92
  |---|---|---|
89
- | `connectMs` — chat request sent → headers | 30 s | `upstream-error`, `recoverable: true` |
90
- | `idleMs` — silence between two stream chunks | 30 s | `upstream-error`, `recoverable: true` |
93
+ | `connectMs` — chat request sent → headers | 30 s | `upstream-error`, `retryable: true` |
94
+ | `idleMs` — silence between two stream chunks | 30 s | `upstream-error`, `retryable: true` |
91
95
  | `configMs` — behavior-config fetch | 10 s | stale cache if any, else `config-unavailable` |
92
- | `quotaMs` — quota fetch | 10 s | `upstream-error`, `recoverable: true` |
96
+ | `quotaMs` — quota fetch | 10 s | `upstream-error`, `retryable: true` |
93
97
 
94
98
  The gateway sends headers only after the model answers, so `connectMs` also
95
99
  covers time to first token. A dropped connection (HTTP status 0) is likewise
96
- `upstream-error` with `recoverable: true`.
100
+ `upstream-error` with `retryable: true`.
97
101
  - **Callbacks**: each `chat()` delivers exactly one of `onDone` / `onError`,
98
102
  and nothing after `cancel()`. An unexpected exception anywhere on the way
99
103
  (including in your own `onDelta`) is reported as `internal`, never dropped.
@@ -115,10 +119,14 @@ Everything is exported from `@chipmobilesdk/rn-ai`; internal `config/`,
115
119
  `ChatHandle`, `Usage`
116
120
  - Behavior and cache: `AppBehaviorConfig`, `AiBehaviorState`, `BehaviorState`,
117
121
  `AiConfigCache`, `QuotaSnapshot`
118
- - React: `useAiChat`, `useAiQuota`, `AiChatStatus`, `UseAiChatResult`,
119
- `UseAiQuotaResult`
122
+ - React: `useAiChat`, `useAiQuota`, `useAiBehaviorState`, `AiChatStatus`,
123
+ `UseAiChatResult`, `UseAiQuotaResult`. `useAiBehaviorState(client)` re-renders when
124
+ the config loads or the `aiEnabled` kill switch flips; `client.subscribe` and
125
+ `client.getBehaviorState()` give the same outside React.
120
126
  - Diagnostics and copy: `AiError`, `AiErrorCode`, `isAiError`, `aiMessages`,
121
- `aiMessage`, `AiMessageKey`
127
+ `aiMessage`, `AiMessageKey`, `aiMessageSet` (the same catalog as an rn-core message
128
+ set), `messageKeyForError(error)` (the copy to show for any error code). Register the
129
+ copy with rn-i18n in one line: `runtimeEntriesFromCatalog(aiMessageSet.user)`.
122
130
 
123
131
  ## App responsibilities
124
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chipmobilesdk/rn-ai",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "main": "src/index.ts",
@@ -21,9 +21,12 @@
21
21
  ],
22
22
  "sideEffects": false,
23
23
  "scripts": {
24
- "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
25
25
  "pack:check": "npm pack --dry-run"
26
26
  },
27
+ "dependencies": {
28
+ "@chipmobilesdk/rn-core": "^0.1.0"
29
+ },
27
30
  "peerDependencies": {
28
31
  "@chipmobilesdk/rn-auth": ">=0.2.0",
29
32
  "react": ">=19",
@@ -1,3 +1,5 @@
1
+ import { createListenerSet, createSingleFlight } from '@chipmobilesdk/rn-core';
2
+
1
3
  import { AiError } from '../diagnostics/errors';
2
4
  import { DEFAULT_AI_TIMEOUTS, withTimeout } from '../transport/timeouts';
3
5
 
@@ -106,7 +108,30 @@ export class BehaviorConfigManager {
106
108
  private fetchFn: typeof fetch;
107
109
  private now: () => number;
108
110
  private current?: { config: AppBehaviorConfig; envelope: CacheEnvelope };
109
- state: BehaviorState = 'unloaded';
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
+ }
110
135
 
111
136
  constructor(private deps: BehaviorConfigDeps) {
112
137
  this.cacheKey = `rn-ai:config:${deps.appId}`;
@@ -119,20 +144,28 @@ export class BehaviorConfigManager {
119
144
  return this.now() - envelope.fetchedAt < config.refreshTtlSeconds * 1000;
120
145
  }
121
146
 
122
- private async loadFromCache(): Promise<void> {
147
+ private loadFromCache(): Promise<void> {
148
+ return this.current ? Promise.resolve() : this.cacheRead.run(() => this.readCache());
149
+ }
150
+
151
+ private async readCache(): Promise<void> {
123
152
  if (this.current) return;
124
153
  const stored = await this.cache.get(this.cacheKey).catch(() => null);
125
154
  if (!stored) return;
126
155
  try {
127
156
  const envelope = JSON.parse(stored) as CacheEnvelope;
128
157
  this.current = { config: parseBehaviorConfig(envelope.configJson), envelope };
129
- this.state = 'ready-cache';
158
+ this.setState('ready-cache');
130
159
  } catch {
131
160
  // corrupted cache entries are ignored, not fatal
132
161
  }
133
162
  }
134
163
 
135
- private async fetchNetwork(): Promise<AppBehaviorConfig | undefined> {
164
+ private fetchNetwork(): Promise<AppBehaviorConfig | undefined> {
165
+ return this.networkFetch.run(() => this.fetchFromNetwork());
166
+ }
167
+
168
+ private async fetchFromNetwork(): Promise<AppBehaviorConfig | undefined> {
136
169
  const headers: Record<string, string> = {};
137
170
  if (this.current?.envelope.etag) {
138
171
  headers['If-None-Match'] = this.current.envelope.etag;
@@ -149,7 +182,7 @@ export class BehaviorConfigManager {
149
182
  if (response.status === 304 && this.current) {
150
183
  this.current.envelope.fetchedAt = this.now();
151
184
  await this.persist(this.current.envelope);
152
- this.state = 'ready-network';
185
+ this.setState('ready-network');
153
186
  return this.current.config;
154
187
  }
155
188
  if (!response.ok) {
@@ -163,7 +196,7 @@ export class BehaviorConfigManager {
163
196
  };
164
197
  this.current = { config, envelope };
165
198
  await this.persist(envelope);
166
- this.state = 'ready-network';
199
+ this.setState('ready-network');
167
200
  return config;
168
201
  }
169
202
 
@@ -189,12 +222,12 @@ export class BehaviorConfigManager {
189
222
  if (fetched) return fetched;
190
223
  } catch (e) {
191
224
  if (this.current) return this.current.config; // stale beats broken
192
- this.state = 'unavailable';
225
+ this.setState('unavailable');
193
226
  throw e instanceof AiError ? e : new AiError('config-unavailable', String(e));
194
227
  }
195
228
  // fetchNetwork only returns undefined when a 304 raced an empty cache.
196
229
  if (this.current) return (this.current as { config: AppBehaviorConfig }).config;
197
- this.state = 'unavailable';
230
+ this.setState('unavailable');
198
231
  throw new AiError('config-unavailable', 'no configuration available');
199
232
  }
200
233
  }
@@ -1,13 +1,30 @@
1
+ import {
2
+ createDiagnosticChannel,
3
+ createListenerSet,
4
+ realScheduler,
5
+ type Scheduler,
6
+ } from '@chipmobilesdk/rn-core';
7
+
1
8
  import {
2
9
  BehaviorConfigManager,
3
10
  type AiConfigCache,
4
11
  type AppBehaviorConfig,
5
12
  type BehaviorState,
6
13
  } from '../config/behaviorConfig';
7
- import { AiError, errorFromHttp } from '../diagnostics/errors';
14
+ import { AiError, errorFromHttp, type AiErrorCode } from '../diagnostics/errors';
8
15
  import { requestSse, type SseHandle } from '../transport/sseClient';
9
16
  import { DEFAULT_AI_TIMEOUTS, withTimeout, type AiTimeouts } from '../transport/timeouts';
10
17
 
18
+ /** Every failure a caller receives also goes to the app's SDK sinks (CORE-08). */
19
+ const channel = createDiagnosticChannel<AiErrorCode>('rn-ai');
20
+
21
+ function report(operation: 'chat' | 'quota', error: AiError): void {
22
+ channel.emit(error.code, error.retryable ? 'warn' : 'error', {
23
+ operation,
24
+ retryable: error.retryable,
25
+ });
26
+ }
27
+
11
28
  /** Minimal slice of the rn-auth `Auth` controller the client needs. */
12
29
  export interface TokenProvider {
13
30
  requestAccessToken(resourceKey: string): Promise<{ status: string; token?: string }>;
@@ -67,12 +84,29 @@ export interface AiClientConfig {
67
84
  transport?: typeof requestSse;
68
85
  /** Overrides for the network time limits; see `AiTimeouts` for the defaults. */
69
86
  timeouts?: Partial<AiTimeouts>;
87
+ /** Test seam: how the backoff before a silent retry is waited out. */
88
+ scheduler?: Scheduler;
89
+ }
90
+
91
+ /** Ceiling on one remote-configured backoff step, so a bad document cannot stall a chat. */
92
+ const MAX_RETRY_BACKOFF_MS = 30_000;
93
+
94
+ /** The wait before silent retry `index` (0-based); the last configured step repeats. */
95
+ function backoffBefore(steps: readonly number[], index: number): number {
96
+ const step = steps[Math.min(index, steps.length - 1)] ?? 0;
97
+ return Math.min(Math.max(0, step), MAX_RETRY_BACKOFF_MS);
70
98
  }
71
99
 
72
100
  export interface AiClient {
73
101
  chat(request: ChatRequest, handlers: ChatStreamHandlers): ChatHandle;
74
102
  getQuota(): Promise<QuotaSnapshot>;
103
+ /** The current behavior state; the same object until it changes. */
75
104
  getBehaviorState(): AiBehaviorState;
105
+ /**
106
+ * Called whenever the behavior state changes (AI-09); returns the unsubscribe.
107
+ * Optional so an app's own `AiClient` implementation keeps compiling.
108
+ */
109
+ subscribe?(listener: () => void): () => void;
76
110
  reloadConfig(): Promise<void>;
77
111
  }
78
112
 
@@ -89,6 +123,7 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
89
123
  const transport = clientConfig.transport ?? requestSse;
90
124
  const fetchFn = clientConfig.fetchFn ?? fetch;
91
125
  const timeouts: AiTimeouts = { ...DEFAULT_AI_TIMEOUTS, ...clientConfig.timeouts };
126
+ const scheduler = clientConfig.scheduler ?? realScheduler;
92
127
  const configManager = new BehaviorConfigManager({
93
128
  configUrl: clientConfig.configUrl,
94
129
  appId: clientConfig.appId,
@@ -97,10 +132,28 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
97
132
  timeoutMs: timeouts.configMs,
98
133
  });
99
134
  let lastConfig: AppBehaviorConfig | undefined;
135
+ const behaviorListeners = createListenerSet<[]>();
136
+ let snapshot: AiBehaviorState | undefined;
137
+
138
+ /** Rebuilt only when a field changes, so useSyncExternalStore sees a stable object. */
139
+ function behaviorState(): AiBehaviorState {
140
+ const config = configManager.state;
141
+ const aiEnabled = lastConfig?.features.aiEnabled ?? true;
142
+ if (!snapshot || snapshot.config !== config || snapshot.aiEnabled !== aiEnabled) {
143
+ snapshot = { config, aiEnabled };
144
+ }
145
+ return snapshot;
146
+ }
147
+
148
+ function setLastConfig(next: AppBehaviorConfig): void {
149
+ const wasEnabled = lastConfig?.features.aiEnabled ?? true;
150
+ lastConfig = next;
151
+ if (next.features.aiEnabled !== wasEnabled) behaviorListeners.notify();
152
+ }
100
153
 
101
154
  async function acquire(): Promise<{ config: AppBehaviorConfig; token: string }> {
102
155
  const config = await configManager.getConfig();
103
- lastConfig = config;
156
+ setLastConfig(config);
104
157
  if (!config.features.aiEnabled) {
105
158
  throw new AiError('config-unavailable', 'AI is disabled for this app', {
106
159
  recoverable: false,
@@ -138,6 +191,7 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
138
191
  if (cancelled || settled) return;
139
192
  settled = true;
140
193
  inner?.cancel();
194
+ report('chat', error);
141
195
  handlers.onError(error);
142
196
  },
143
197
  };
@@ -220,8 +274,16 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
220
274
  config.recovery.reloadOnHttpStatus.includes(status) &&
221
275
  state.configReloads < config.recovery.maxSilentRetries
222
276
  ) {
277
+ // Waits `recovery.retryBackoffMs` first (AI-05): a gateway mid-rollout
278
+ // that answered 410 a moment ago may well answer it again at once.
279
+ const retryAfter = backoffBefore(config.recovery.retryBackoffMs, state.configReloads);
223
280
  configManager.getConfig({ forceReload: true }).then(
224
- () => attempt({ ...state, configReloads: state.configReloads + 1 }).catch(fail),
281
+ () => {
282
+ scheduler.setTimeout(() => {
283
+ if (cancelled) return;
284
+ attempt({ ...state, configReloads: state.configReloads + 1 }).catch(fail);
285
+ }, retryAfter);
286
+ },
225
287
  e => fail(e instanceof AiError ? e : new AiError('config-unavailable', String(e))),
226
288
  );
227
289
  return;
@@ -247,6 +309,15 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
247
309
  }
248
310
 
249
311
  async function getQuota(): Promise<QuotaSnapshot> {
312
+ try {
313
+ return await fetchQuota();
314
+ } catch (error) {
315
+ if (error instanceof AiError) report('quota', error);
316
+ throw error;
317
+ }
318
+ }
319
+
320
+ async function fetchQuota(): Promise<QuotaSnapshot> {
250
321
  const { config, token } = await acquire();
251
322
  return withTimeout(
252
323
  timeouts.quotaMs,
@@ -267,12 +338,17 @@ export function createAiClient(clientConfig: AiClientConfig): AiClient {
267
338
  return {
268
339
  chat,
269
340
  getQuota,
270
- getBehaviorState: () => ({
271
- config: configManager.state,
272
- aiEnabled: lastConfig?.features.aiEnabled ?? true,
273
- }),
341
+ getBehaviorState: () => behaviorState(),
342
+ subscribe: listener => {
343
+ const fromManager = configManager.subscribe(listener);
344
+ const fromClient = behaviorListeners.add(listener);
345
+ return () => {
346
+ fromManager();
347
+ fromClient();
348
+ };
349
+ },
274
350
  reloadConfig: async () => {
275
- lastConfig = await configManager.getConfig({ forceReload: true });
351
+ setLastConfig(await configManager.getConfig({ forceReload: true }));
276
352
  },
277
353
  };
278
354
  }
@@ -1,4 +1,6 @@
1
1
  /** Typed client errors (contracts/rn-ai-public-api.md). */
2
+ import { SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
3
+
2
4
  export type AiErrorCode =
3
5
  | 'auth-invalid'
4
6
  | 'not-entitled'
@@ -12,21 +14,33 @@ export type AiErrorCode =
12
14
  | 'upstream-error'
13
15
  | 'internal';
14
16
 
15
- export class AiError extends Error {
16
- readonly code: AiErrorCode;
17
- readonly retryAfterSeconds?: number;
18
- readonly recoverable: boolean;
17
+ export interface AiErrorOptions {
18
+ retryAfterSeconds?: number | undefined;
19
+ /** Whether retrying can succeed. Defaults to true for `rate-limited` and `upstream-busy`. */
20
+ retryable?: boolean | undefined;
21
+ /** @deprecated Use `retryable`; accepted as its alias. */
22
+ recoverable?: boolean | undefined;
23
+ }
24
+
25
+ /** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-ai'`. */
26
+ export class AiError extends SdkError<AiErrorCode> {
27
+ readonly retryAfterSeconds?: number | undefined;
19
28
 
20
- constructor(
21
- code: AiErrorCode,
22
- message: string,
23
- options: { retryAfterSeconds?: number; recoverable?: boolean } = {},
24
- ) {
25
- super(message);
29
+ constructor(code: AiErrorCode, message: string, options: AiErrorOptions = {}) {
30
+ super('rn-ai', code, {
31
+ message,
32
+ retryable:
33
+ options.retryable ??
34
+ options.recoverable ??
35
+ (code === 'rate-limited' || code === 'upstream-busy'),
36
+ });
26
37
  this.name = 'AiError';
27
- this.code = code;
28
38
  this.retryAfterSeconds = options.retryAfterSeconds;
29
- this.recoverable = options.recoverable ?? (code === 'rate-limited' || code === 'upstream-busy');
39
+ }
40
+
41
+ /** @deprecated Use `retryable`, the name every SDK package shares. */
42
+ get recoverable(): boolean {
43
+ return this.retryable;
30
44
  }
31
45
  }
32
46
 
@@ -58,7 +72,8 @@ export function errorFromHttp(status: number, bodyText: string): AiError {
58
72
  if (parsed.error?.code && HTTP_CODE_MAP[parsed.error.code]) {
59
73
  code = HTTP_CODE_MAP[parsed.error.code];
60
74
  }
61
- if (parsed.error?.message) message = parsed.error.message;
75
+ // The gateway's text can quote upstream detail; keep it readable but shaped.
76
+ if (parsed.error?.message) message = redactPatterns(parsed.error.message);
62
77
  retryAfterSeconds = parsed.error?.retryAfterSeconds;
63
78
  } catch {
64
79
  if (status === 0) {
@@ -1,27 +1,63 @@
1
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.
2
+ * User-facing copy keys (UX-002/003) and their English defaults, as an rn-core
3
+ * message catalog: apps translate them through rn-i18n
4
+ * (`runtimeEntriesFromCatalog(aiMessages)`) or override them directly.
4
5
  */
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;
6
+ import { defineMessages } from '@chipmobilesdk/rn-core';
7
+
8
+ import type { AiErrorCode } from './errors';
9
+ import type { AiError } from './errors';
10
+
11
+ export const aiMessageSet = defineMessages({
12
+ user: {
13
+ 'ai.state.loading': 'Thinking…',
14
+ 'ai.state.streaming': 'Answering…',
15
+ // Neutral copy: the app names its own feature (AI-16).
16
+ 'ai.input.placeholder': 'Type a message…',
17
+ 'ai.action.send': 'Send',
18
+ 'ai.action.cancel': 'Cancel',
19
+ 'ai.action.retry': 'Try again',
20
+ 'ai.action.retryAt': 'You can try again at {time}',
21
+ 'ai.quota.remaining': '{count} requests left today',
22
+ 'ai.error.rateLimited': 'You have reached your usage limit.',
23
+ 'ai.error.premiumRequired': 'AI features require a premium subscription for this app.',
24
+ 'ai.error.blocked': 'AI access is suspended for this account.',
25
+ 'ai.error.payloadTooLarge': 'That attachment is too large to send.',
26
+ 'ai.error.modality': 'This kind of content is not supported here.',
27
+ 'ai.error.busy': 'The AI service is busy. Please try again shortly.',
28
+ 'ai.error.unavailable': 'AI features are currently unavailable.',
29
+ 'ai.error.signIn': 'Please sign in again to continue.',
30
+ 'ai.error.generic': 'Something went wrong. Please try again.',
31
+ },
32
+ });
33
+
34
+ export const aiMessages = aiMessageSet.defaults;
22
35
 
23
36
  export type AiMessageKey = keyof typeof aiMessages;
24
37
 
25
38
  export function aiMessage(key: AiMessageKey): string {
26
39
  return aiMessages[key];
27
40
  }
41
+
42
+ /**
43
+ * The copy for each error code. A `Record` over the whole code union, so adding a
44
+ * code without choosing its copy fails to compile (AI-16).
45
+ */
46
+ const ERROR_MESSAGES: Record<AiErrorCode, AiMessageKey> = {
47
+ 'auth-invalid': 'ai.error.signIn',
48
+ 'not-entitled': 'ai.error.premiumRequired',
49
+ 'user-blocked': 'ai.error.blocked',
50
+ 'app-unknown': 'ai.error.unavailable',
51
+ 'rate-limited': 'ai.error.rateLimited',
52
+ 'upstream-busy': 'ai.error.busy',
53
+ 'payload-too-large': 'ai.error.payloadTooLarge',
54
+ 'modality-not-allowed': 'ai.error.modality',
55
+ 'config-unavailable': 'ai.error.unavailable',
56
+ 'upstream-error': 'ai.error.generic',
57
+ internal: 'ai.error.generic',
58
+ };
59
+
60
+ /** The message key to show a user for an error. */
61
+ export function messageKeyForError(error: AiError): AiMessageKey {
62
+ return ERROR_MESSAGES[error.code];
63
+ }
package/src/index.ts CHANGED
@@ -22,10 +22,10 @@ export type {
22
22
  export type { AppBehaviorConfig, AiConfigCache, BehaviorState } from './config/behaviorConfig';
23
23
 
24
24
  export type { AiTimeouts } from './transport/timeouts';
25
- export { useAiChat, useAiQuota } from './react/hooks';
25
+ export { useAiBehaviorState, useAiChat, useAiQuota } from './react/hooks';
26
26
  export type { AiChatStatus, UseAiChatResult, UseAiQuotaResult } from './react/hooks';
27
27
 
28
28
  export { AiError, isAiError } from './diagnostics/errors';
29
29
  export type { AiErrorCode } from './diagnostics/errors';
30
- export { aiMessages, aiMessage } from './diagnostics/messageKeys';
30
+ export { aiMessage, aiMessageSet, aiMessages, messageKeyForError } from './diagnostics/messageKeys';
31
31
  export type { AiMessageKey } from './diagnostics/messageKeys';
@@ -1,5 +1,12 @@
1
+ import { useExternalStore } from '@chipmobilesdk/rn-core/react';
1
2
  import { useCallback, useEffect, useRef, useState } from 'react';
2
- import type { AiClient, ChatHandle, ChatRequest, QuotaSnapshot } from '../core/aiClient';
3
+ import type {
4
+ AiBehaviorState,
5
+ AiClient,
6
+ ChatHandle,
7
+ ChatRequest,
8
+ QuotaSnapshot,
9
+ } from '../core/aiClient';
3
10
  import type { AiError } from '../diagnostics/errors';
4
11
 
5
12
  export type AiChatStatus = 'idle' | 'loading' | 'streaming' | 'done' | 'error' | 'rate-limited';
@@ -121,3 +128,14 @@ export function useAiQuota(client: AiClient): UseAiQuotaResult {
121
128
 
122
129
  return { quota, status, refresh };
123
130
  }
131
+
132
+ /**
133
+ * The client's behavior state (config source and whether AI is enabled), live:
134
+ * re-renders when the kill switch flips or the config state moves (AI-09).
135
+ */
136
+ export function useAiBehaviorState(client: AiClient): AiBehaviorState {
137
+ return useExternalStore(client.subscribe ?? noSubscription, client.getBehaviorState);
138
+ }
139
+
140
+ /** For a client without `subscribe`: its state is read once per render. */
141
+ const noSubscription = () => () => undefined;