@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 +87 -0
- package/README.md +17 -9
- package/package.json +5 -2
- package/src/config/behaviorConfig.ts +41 -8
- package/src/core/aiClient.ts +84 -8
- package/src/diagnostics/errors.ts +28 -13
- package/src/diagnostics/messageKeys.ts +55 -19
- package/src/index.ts +2 -2
- package/src/react/hooks.ts +19 -1
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
|
-
|
|
81
|
-
|
|
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`, `
|
|
90
|
-
| `idleMs` — silence between two stream chunks | 30 s | `upstream-error`, `
|
|
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`, `
|
|
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 `
|
|
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`, `
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
230
|
+
this.setState('unavailable');
|
|
198
231
|
throw new AiError('config-unavailable', 'no configuration available');
|
|
199
232
|
}
|
|
200
233
|
}
|
package/src/core/aiClient.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
() =>
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
3
|
-
*
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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,
|
|
30
|
+
export { aiMessage, aiMessageSet, aiMessages, messageKeyForError } from './diagnostics/messageKeys';
|
|
31
31
|
export type { AiMessageKey } from './diagnostics/messageKeys';
|
package/src/react/hooks.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import { useExternalStore } from '@chipmobilesdk/rn-core/react';
|
|
1
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import type {
|
|
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;
|