@chipmobilesdk/rn-ai 0.3.0 → 0.5.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 +146 -0
- package/README.md +65 -24
- package/lib/commonjs/config/behaviorConfig.js +283 -0
- package/lib/commonjs/config/behaviorConfig.js.map +1 -0
- package/lib/commonjs/contract/gateway.js +60 -0
- package/lib/commonjs/contract/gateway.js.map +1 -0
- package/lib/commonjs/core/aiClient.js +330 -0
- package/lib/commonjs/core/aiClient.js.map +1 -0
- package/lib/commonjs/core/quotaRequest.js +105 -0
- package/lib/commonjs/core/quotaRequest.js.map +1 -0
- package/lib/commonjs/diagnostics/errors.js +108 -0
- package/lib/commonjs/diagnostics/errors.js.map +1 -0
- package/lib/commonjs/diagnostics/messageKeys.js +68 -0
- package/lib/commonjs/diagnostics/messageKeys.js.map +1 -0
- package/lib/commonjs/index.js +102 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/react/hooks.js +170 -0
- package/lib/commonjs/react/hooks.js.map +1 -0
- package/lib/commonjs/transport/sseClient.js +151 -0
- package/lib/commonjs/transport/sseClient.js.map +1 -0
- package/lib/commonjs/transport/timeouts.js +46 -0
- package/lib/commonjs/transport/timeouts.js.map +1 -0
- package/lib/module/config/behaviorConfig.js +275 -0
- package/lib/module/config/behaviorConfig.js.map +1 -0
- package/lib/module/contract/gateway.js +56 -0
- package/lib/module/contract/gateway.js.map +1 -0
- package/lib/module/core/aiClient.js +327 -0
- package/lib/module/core/aiClient.js.map +1 -0
- package/lib/module/core/quotaRequest.js +102 -0
- package/lib/module/core/quotaRequest.js.map +1 -0
- package/lib/module/diagnostics/errors.js +99 -0
- package/lib/module/diagnostics/errors.js.map +1 -0
- package/lib/module/diagnostics/messageKeys.js +61 -0
- package/lib/module/diagnostics/messageKeys.js.map +1 -0
- package/lib/module/index.js +19 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/react/hooks.js +164 -0
- package/lib/module/react/hooks.js.map +1 -0
- package/lib/module/transport/sseClient.js +146 -0
- package/lib/module/transport/sseClient.js.map +1 -0
- package/lib/module/transport/timeouts.js +41 -0
- package/lib/module/transport/timeouts.js.map +1 -0
- package/lib/typescript/commonjs/config/behaviorConfig.d.ts +119 -0
- package/lib/typescript/commonjs/config/behaviorConfig.d.ts.map +1 -0
- package/lib/typescript/commonjs/contract/gateway.d.ts +64 -0
- package/lib/typescript/commonjs/contract/gateway.d.ts.map +1 -0
- package/lib/typescript/commonjs/core/aiClient.d.ts +104 -0
- package/lib/typescript/commonjs/core/aiClient.d.ts.map +1 -0
- package/lib/typescript/commonjs/core/quotaRequest.d.ts +45 -0
- package/lib/typescript/commonjs/core/quotaRequest.d.ts.map +1 -0
- package/lib/typescript/commonjs/diagnostics/errors.d.ts +38 -0
- package/lib/typescript/commonjs/diagnostics/errors.d.ts.map +1 -0
- package/lib/typescript/commonjs/diagnostics/messageKeys.d.ts +46 -0
- package/lib/typescript/commonjs/diagnostics/messageKeys.d.ts.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +21 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/react/hooks.d.ts +37 -0
- package/lib/typescript/commonjs/react/hooks.d.ts.map +1 -0
- package/lib/typescript/commonjs/transport/sseClient.d.ts +57 -0
- package/lib/typescript/commonjs/transport/sseClient.d.ts.map +1 -0
- package/lib/typescript/commonjs/transport/timeouts.d.ts +28 -0
- package/lib/typescript/commonjs/transport/timeouts.d.ts.map +1 -0
- package/lib/typescript/module/config/behaviorConfig.d.ts +119 -0
- package/lib/typescript/module/config/behaviorConfig.d.ts.map +1 -0
- package/lib/typescript/module/contract/gateway.d.ts +64 -0
- package/lib/typescript/module/contract/gateway.d.ts.map +1 -0
- package/lib/typescript/module/core/aiClient.d.ts +104 -0
- package/lib/typescript/module/core/aiClient.d.ts.map +1 -0
- package/lib/typescript/module/core/quotaRequest.d.ts +45 -0
- package/lib/typescript/module/core/quotaRequest.d.ts.map +1 -0
- package/lib/typescript/module/diagnostics/errors.d.ts +38 -0
- package/lib/typescript/module/diagnostics/errors.d.ts.map +1 -0
- package/lib/typescript/module/diagnostics/messageKeys.d.ts +46 -0
- package/lib/typescript/module/diagnostics/messageKeys.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +21 -0
- package/lib/typescript/module/index.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/react/hooks.d.ts +37 -0
- package/lib/typescript/module/react/hooks.d.ts.map +1 -0
- package/lib/typescript/module/transport/sseClient.d.ts +57 -0
- package/lib/typescript/module/transport/sseClient.d.ts.map +1 -0
- package/lib/typescript/module/transport/timeouts.d.ts +28 -0
- package/lib/typescript/module/transport/timeouts.d.ts.map +1 -0
- package/package.json +40 -12
- package/skills/sdk-rn-ai/SKILL.md +43 -4
- package/src/config/behaviorConfig.ts +158 -37
- package/src/contract/gateway.ts +91 -0
- package/src/core/aiClient.ts +194 -96
- package/src/core/quotaRequest.ts +125 -0
- package/src/diagnostics/errors.ts +85 -23
- package/src/diagnostics/messageKeys.ts +3 -0
- package/src/index.ts +28 -1
- package/src/react/hooks.ts +86 -14
- package/src/transport/sseClient.ts +58 -11
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAiB,CAAC;AACjD,YAAY,EACX,QAAQ,EACR,cAAc,EACd,eAAe,EACf,aAAa,EACb,yBAAyB,EACzB,WAAW,EACX,WAAW,EACX,WAAW,EACX,kBAAkB,EAClB,UAAU,EACV,KAAK,EACL,aAAa,GACb,MAAM,oBAAiB,CAAC;AAEzB,YAAY,EAAE,iBAAiB,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,4BAAyB,CAAC;AAC/F,OAAO,EAAE,uBAAuB,EAAE,wBAAwB,EAAE,MAAM,4BAAyB,CAAC;AAI5F,YAAY,EACX,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,kBAAkB,EAClB,YAAY,EACZ,eAAe,EACf,gBAAgB,GAChB,MAAM,uBAAoB,CAAC;AAC5B,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,uBAAoB,CAAC;AAE5F,YAAY,EAAE,UAAU,EAAE,MAAM,yBAAsB,CAAC;AACvD,YAAY,EACX,UAAU,EACV,WAAW,EACX,SAAS,EACT,eAAe,EACf,eAAe,GACf,MAAM,0BAAuB,CAAC;AAC/B,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,kBAAe,CAAC;AAC1E,YAAY,EACX,YAAY,EACZ,gBAAgB,EAChB,eAAe,EACf,gBAAgB,GAChB,MAAM,kBAAe,CAAC;AAEvB,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAsB,CAAC;AAC1D,YAAY,EAAE,WAAW,EAAE,MAAM,yBAAsB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,8BAA2B,CAAC;AACpG,YAAY,EAAE,YAAY,EAAE,MAAM,8BAA2B,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AiBehaviorState, AiClient, ChatRequest, QuotaSnapshot } from '../core/aiClient.js';
|
|
2
|
+
import type { AiError } from '../diagnostics/errors.js';
|
|
3
|
+
export type AiChatStatus = 'idle' | 'loading' | 'streaming' | 'done' | 'error' | 'rate-limited';
|
|
4
|
+
export interface UseAiChatResult {
|
|
5
|
+
send(request: ChatRequest): void;
|
|
6
|
+
cancel(): void;
|
|
7
|
+
status: AiChatStatus;
|
|
8
|
+
/** Accumulated streamed text, flushed on an interval rather than per token. */
|
|
9
|
+
text: string;
|
|
10
|
+
error?: AiError;
|
|
11
|
+
retryAt?: Date;
|
|
12
|
+
quota?: QuotaSnapshot;
|
|
13
|
+
}
|
|
14
|
+
export interface UseAiChatOptions {
|
|
15
|
+
/**
|
|
16
|
+
* How often the accumulated text reaches the screen, in milliseconds.
|
|
17
|
+
*
|
|
18
|
+
* A model streams 30–100 tokens a second, and a render per token is 30–100
|
|
19
|
+
* renders of the whole transcript a second on the JS thread (PERF-12). At the
|
|
20
|
+
* default a long answer costs about 20 renders a second instead, and the last
|
|
21
|
+
* tokens are always flushed with the terminal state. `0` renders per token.
|
|
22
|
+
*/
|
|
23
|
+
flushIntervalMs?: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function useAiChat(client: AiClient, options?: UseAiChatOptions): UseAiChatResult;
|
|
26
|
+
export interface UseAiQuotaResult {
|
|
27
|
+
quota?: QuotaSnapshot;
|
|
28
|
+
status: 'loading' | 'ready' | 'error';
|
|
29
|
+
refresh(): void;
|
|
30
|
+
}
|
|
31
|
+
export declare function useAiQuota(client: AiClient): UseAiQuotaResult;
|
|
32
|
+
/**
|
|
33
|
+
* The client's behavior state (config source and whether AI is enabled), live:
|
|
34
|
+
* re-renders when the kill switch flips or the config state moves (AI-09).
|
|
35
|
+
*/
|
|
36
|
+
export declare function useAiBehaviorState(client: AiClient): AiBehaviorState;
|
|
37
|
+
//# sourceMappingURL=hooks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../../../src/react/hooks.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACX,eAAe,EACf,QAAQ,EAER,WAAW,EACX,aAAa,EACb,MAAM,qBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,0BAAuB,CAAC;AAErD,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,SAAS,GAAG,WAAW,GAAG,MAAM,GAAG,OAAO,GAAG,cAAc,CAAC;AAEhG,MAAM,WAAW,eAAe;IAC/B,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAAC;IACjC,MAAM,IAAI,IAAI,CAAC;IACf,MAAM,EAAE,YAAY,CAAC;IACrB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,IAAI,CAAC;IACf,KAAK,CAAC,EAAE,aAAa,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAChC;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CACzB;AAID,wBAAgB,SAAS,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAE,gBAAqB,GAAG,eAAe,CA2G3F;AAED,MAAM,WAAW,gBAAgB;IAChC,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,MAAM,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;IACtC,OAAO,IAAI,IAAI,CAAC;CAChB;AAgBD,wBAAgB,UAAU,CAAC,MAAM,EAAE,QAAQ,GAAG,gBAAgB,CAqC7D;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,QAAQ,GAAG,eAAe,CAEpE"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SSE reader over XMLHttpRequest (research R2).
|
|
3
|
+
*
|
|
4
|
+
* React Native's fetch does not reliably expose ReadableStream on Hermes, so
|
|
5
|
+
* we use XHR incremental text delivery (readyState LOADING + onprogress) —
|
|
6
|
+
* the same mechanism community SSE libraries use — with zero dependencies.
|
|
7
|
+
* The gateway never compresses SSE responses (contract), which keeps
|
|
8
|
+
* incremental delivery intact on both platforms.
|
|
9
|
+
*/
|
|
10
|
+
export interface SseRequest {
|
|
11
|
+
url: string;
|
|
12
|
+
method?: 'GET' | 'POST';
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
/** JSON string body for POST requests. */
|
|
15
|
+
body?: string;
|
|
16
|
+
/** Hard limit on the whole request. Rarely right for a stream; prefer the two below. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
/** Fails with `onError(0, 'timeout')` if response headers have not arrived by then. */
|
|
19
|
+
connectTimeoutMs?: number;
|
|
20
|
+
/** Fails with `onError(0, 'timeout')` when no chunk arrives for this long. Reset per chunk. */
|
|
21
|
+
idleTimeoutMs?: number;
|
|
22
|
+
}
|
|
23
|
+
/** What the response said besides its body. Absent for a failure with no response. */
|
|
24
|
+
export interface SseResponseInfo {
|
|
25
|
+
/** A response header, or null. `Retry-After` is the one this package reads. */
|
|
26
|
+
getHeader(name: string): string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface SseHandlers {
|
|
29
|
+
/** One call per `data:` event payload (raw string, already de-framed). */
|
|
30
|
+
onData(payload: string): void;
|
|
31
|
+
/** Stream closed normally (transport level; `[DONE]` handling is the caller's). */
|
|
32
|
+
onEnd(): void;
|
|
33
|
+
/** Transport or HTTP error. `status` is 0 for network failures. */
|
|
34
|
+
onError(status: number, bodyText: string, response?: SseResponseInfo): void;
|
|
35
|
+
}
|
|
36
|
+
export interface SseHandle {
|
|
37
|
+
cancel(): void;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The transport port: how this package makes a streaming request.
|
|
41
|
+
*
|
|
42
|
+
* A published port rather than `typeof requestSse`, so the default reader's
|
|
43
|
+
* own signature is not part of the public API (AI-18): an app may inject its
|
|
44
|
+
* own transport, and this package may change how `requestSse` is written
|
|
45
|
+
* without that being a breaking change.
|
|
46
|
+
*/
|
|
47
|
+
export type StreamTransport = (request: SseRequest, handlers: SseHandlers) => SseHandle;
|
|
48
|
+
/**
|
|
49
|
+
* Parse complete `data:` events out of `text` starting at `fromIndex`.
|
|
50
|
+
*
|
|
51
|
+
* Both line endings are accepted. A proxy that normalises to CRLF — an API
|
|
52
|
+
* gateway or a corporate middlebox — used to make every event invisible until
|
|
53
|
+
* the stream closed, because only `\n\n` counted as a separator (AI-22).
|
|
54
|
+
*/
|
|
55
|
+
export declare function drainSseEvents(text: string, fromIndex: number, emit: (payload: string) => void): number;
|
|
56
|
+
export declare function requestSse(request: SseRequest, handlers: SseHandlers): SseHandle;
|
|
57
|
+
//# sourceMappingURL=sseClient.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sseClient.d.ts","sourceRoot":"","sources":["../../../../src/transport/sseClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,UAAU;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,0CAA0C;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wFAAwF;IACxF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uFAAuF;IACvF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+FAA+F;IAC/F,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,sFAAsF;AACtF,MAAM,WAAW,eAAe;IAC/B,+EAA+E;IAC/E,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACvC;AAED,MAAM,WAAW,WAAW;IAC3B,0EAA0E;IAC1E,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,mFAAmF;IACnF,KAAK,IAAI,IAAI,CAAC;IACd,mEAAmE;IACnE,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;CAC5E;AAED,MAAM,WAAW,SAAS;IACzB,MAAM,IAAI,IAAI,CAAC;CACf;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,WAAW,KAAK,SAAS,CAAC;AAExF;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC7B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAC7B,MAAM,CAiBR;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,WAAW,GAAG,SAAS,CA0GhF"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time limits for every network call the client makes (AI-04).
|
|
3
|
+
*
|
|
4
|
+
* Without them a stalled mobile connection left `useAiChat` in `loading` or
|
|
5
|
+
* `streaming`, and `useAiQuota` in `loading`, until the user gave up.
|
|
6
|
+
*/
|
|
7
|
+
export interface AiTimeouts {
|
|
8
|
+
/** Request sent → response headers received, for the chat stream. */
|
|
9
|
+
connectMs: number;
|
|
10
|
+
/** Longest silence allowed between two chunks of the chat stream. */
|
|
11
|
+
idleMs: number;
|
|
12
|
+
/** Behavior-config fetch, including reading the body. */
|
|
13
|
+
configMs: number;
|
|
14
|
+
/** Quota fetch, including reading the body. */
|
|
15
|
+
quotaMs: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The gateway sends headers only once the model has answered, so `connectMs`
|
|
19
|
+
* also covers time to first token on a large image prompt.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEFAULT_AI_TIMEOUTS: AiTimeouts;
|
|
22
|
+
/**
|
|
23
|
+
* Runs `run` with an abort signal and rejects with `onTimeout()` after `ms`.
|
|
24
|
+
* The race matters as much as the signal: an injected `fetchFn` (or an old
|
|
25
|
+
* polyfill) may ignore the signal entirely.
|
|
26
|
+
*/
|
|
27
|
+
export declare function withTimeout<T>(ms: number, run: (signal: AbortSignal | undefined) => Promise<T>, onTimeout: () => Error): Promise<T>;
|
|
28
|
+
//# sourceMappingURL=timeouts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timeouts.d.ts","sourceRoot":"","sources":["../../../../src/transport/timeouts.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,UAAU;IAC1B,qEAAqE;IACrE,SAAS,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,yDAAyD;IACzD,QAAQ,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,eAAO,MAAM,mBAAmB,EAAE,UAKjC,CAAC;AAEF;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,CAAC,EAClC,EAAE,EAAE,MAAM,EACV,GAAG,EAAE,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,KAAK,OAAO,CAAC,CAAC,CAAC,EACpD,SAAS,EAAE,MAAM,KAAK,GACpB,OAAO,CAAC,CAAC,CAAC,CAcZ"}
|
package/package.json
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
6
|
+
"source": "./src/index.ts",
|
|
7
|
+
"main": "./lib/commonjs/index.js",
|
|
8
|
+
"module": "./lib/module/index.js",
|
|
9
|
+
"types": "./lib/typescript/commonjs/index.d.ts",
|
|
8
10
|
"exports": {
|
|
9
11
|
".": {
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
12
|
+
"chipmobilesdk-source": "./src/index.ts",
|
|
13
|
+
"import": {
|
|
14
|
+
"types": "./lib/typescript/module/index.d.ts",
|
|
15
|
+
"default": "./lib/module/index.js"
|
|
16
|
+
},
|
|
17
|
+
"require": {
|
|
18
|
+
"types": "./lib/typescript/commonjs/index.d.ts",
|
|
19
|
+
"default": "./lib/commonjs/index.js"
|
|
20
|
+
}
|
|
13
21
|
}
|
|
14
22
|
},
|
|
15
23
|
"files": [
|
|
@@ -17,23 +25,43 @@
|
|
|
17
25
|
"!src/**/__tests__",
|
|
18
26
|
"skills",
|
|
19
27
|
"README.md",
|
|
20
|
-
"CHANGELOG.md"
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"lib"
|
|
21
30
|
],
|
|
22
31
|
"sideEffects": false,
|
|
23
32
|
"scripts": {
|
|
33
|
+
"build": "bob build",
|
|
24
34
|
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
|
|
25
|
-
"pack:check": "npm pack --dry-run"
|
|
35
|
+
"pack:check": "npm pack --dry-run",
|
|
36
|
+
"prepack": "npm run build"
|
|
26
37
|
},
|
|
27
38
|
"dependencies": {
|
|
28
|
-
"@chipmobilesdk/rn-core": "^0.
|
|
39
|
+
"@chipmobilesdk/rn-core": "^0.2.0"
|
|
29
40
|
},
|
|
30
41
|
"peerDependencies": {
|
|
31
|
-
"
|
|
32
|
-
"react": ">=19",
|
|
33
|
-
"react-native": ">=0.85"
|
|
42
|
+
"react": ">=19"
|
|
34
43
|
},
|
|
35
44
|
"publishConfig": {
|
|
36
45
|
"access": "public",
|
|
37
46
|
"registry": "https://registry.npmjs.org/"
|
|
47
|
+
},
|
|
48
|
+
"react-native-builder-bob": {
|
|
49
|
+
"source": "src",
|
|
50
|
+
"output": "lib",
|
|
51
|
+
"targets": [
|
|
52
|
+
[
|
|
53
|
+
"commonjs",
|
|
54
|
+
{
|
|
55
|
+
"esm": true
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
"module",
|
|
60
|
+
{
|
|
61
|
+
"esm": true
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"typescript"
|
|
65
|
+
]
|
|
38
66
|
}
|
|
39
67
|
}
|
|
@@ -19,6 +19,14 @@ and a typed error model.
|
|
|
19
19
|
- Handling AI request failure, cancellation, or streaming state
|
|
20
20
|
- Localising AI-related user messages
|
|
21
21
|
|
|
22
|
+
## The token source is an interface, not rn-auth
|
|
23
|
+
|
|
24
|
+
`createAiClient` takes `auth: TokenProvider` — one method,
|
|
25
|
+
`requestAccessToken(resourceKey, { forceRefresh })`. rn-auth's controller satisfies it,
|
|
26
|
+
so passing `auth` straight in is the usual wiring, but rn-auth is not a dependency or a
|
|
27
|
+
peer of this package: an app with its own token source implements the interface and
|
|
28
|
+
installs nothing extra. Do not reach for anything else on the object you pass.
|
|
29
|
+
|
|
22
30
|
## The app owns more than you expect
|
|
23
31
|
|
|
24
32
|
This package deliberately does not handle everything. The README has an
|
|
@@ -31,18 +39,49 @@ read it before assuming a behaviour is built in.
|
|
|
31
39
|
screen unmounts, otherwise the response resolves against a dead component. Verify
|
|
32
40
|
teardown on every screen that uses it.
|
|
33
41
|
|
|
42
|
+
## Cache the config, or every launch waits for the network
|
|
43
|
+
|
|
44
|
+
`createAiClient({ storage })` takes an `AiConfigCache`. Without one the behavior config
|
|
45
|
+
lives in memory only, so each cold start blocks the first chat on a network fetch.
|
|
46
|
+
`configCacheFromKeyValue(port)` adapts rn-core's `KeyValuePort` (MMKV through
|
|
47
|
+
`openDurable`), which is how the demo app wires it.
|
|
48
|
+
|
|
49
|
+
## What the config document is allowed to say
|
|
50
|
+
|
|
51
|
+
`configUrl` and the document's `api.baseUrl` must be https (localhost excepted), the
|
|
52
|
+
document must name the same `appId`, and its `schemaVersion` must be one the installed
|
|
53
|
+
release supports. `allowedHosts` pins the hosts the document may point at. A document
|
|
54
|
+
that breaks any of these is a `config-unavailable` error, not a request sent anyway:
|
|
55
|
+
this is where the app's bearer token would go.
|
|
56
|
+
|
|
57
|
+
## Errors carry a reason
|
|
58
|
+
|
|
59
|
+
`AiError.code` is what happened; `AiError.cause` is the code underneath (the gateway's,
|
|
60
|
+
or rn-auth's). `network` means the request never arrived and a retry may work;
|
|
61
|
+
`session-expired` means sign-in is needed; `not-entitled` means premium. Use
|
|
62
|
+
`messageKeyForError(error)` for the copy — one key per code, nothing to map by hand.
|
|
63
|
+
|
|
34
64
|
## Public API
|
|
35
65
|
|
|
36
66
|
Runtime:
|
|
37
67
|
|
|
38
|
-
- `createAiClient(...)` —
|
|
39
|
-
|
|
40
|
-
- `
|
|
68
|
+
- `createAiClient(...)` — the client: `chat`, `getQuota`, `lastQuota`, `reloadConfig`,
|
|
69
|
+
`getBehaviorState`, `subscribe`
|
|
70
|
+
- `useAiChat(client, { flushIntervalMs })` → `UseAiChatResult` (`send`, `cancel`,
|
|
71
|
+
`status`, `text`, `error`, `retryAt`, `quota`), with `AiChatStatus` for state
|
|
72
|
+
- `useAiQuota(...)` → `UseAiQuotaResult`; `useAiBehaviorState(...)` for the kill switch
|
|
73
|
+
- `configCacheFromKeyValue(port)`, `SUPPORTED_SCHEMA_VERSION`
|
|
41
74
|
- `AiError`, `isAiError` — narrow before reading `AiErrorCode`
|
|
42
|
-
- `aiMessages`, `aiMessage`
|
|
75
|
+
- `aiMessages`, `aiMessage`, `aiMessageSet`, `messageKeyForError`
|
|
76
|
+
- `GATEWAY_ERROR_STATUS`, `QUOTA_HEADERS`, `SSE_DONE_SENTINEL` — the gateway contract
|
|
43
77
|
|
|
44
78
|
Use `aiMessage` for anything shown to the user. Never render `AiError.message` directly.
|
|
45
79
|
|
|
80
|
+
## Streamed text is batched
|
|
81
|
+
|
|
82
|
+
`useAiChat` flushes accumulated deltas every 50 ms rather than rendering per token; pass
|
|
83
|
+
`flushIntervalMs: 0` for per-token rendering. Do not add your own buffering on top.
|
|
84
|
+
|
|
46
85
|
## Where the detail lives
|
|
47
86
|
|
|
48
87
|
Read one section of `node_modules/@chipmobilesdk/rn-ai/README.md`:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createListenerSet, createSingleFlight } from '@chipmobilesdk/rn-core';
|
|
1
|
+
import { createListenerSet, createSingleFlight, type KeyValuePort } from '@chipmobilesdk/rn-core';
|
|
2
2
|
|
|
3
3
|
import { AiError } from '../diagnostics/errors';
|
|
4
4
|
import { DEFAULT_AI_TIMEOUTS, withTimeout } from '../transport/timeouts';
|
|
@@ -35,18 +35,84 @@ export class MemoryConfigCache implements AiConfigCache {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* An `AiConfigCache` over rn-core's synchronous `KeyValuePort` (AI-10).
|
|
40
|
+
*
|
|
41
|
+
* Without a durable cache every launch waits for the network before the first
|
|
42
|
+
* chat, which is exactly what the cache-first design exists to avoid. Open the
|
|
43
|
+
* port the way the app opens its other durable state, for example
|
|
44
|
+
* `openDurable(() => createMMKV({ id: 'app' }))`.
|
|
45
|
+
*/
|
|
46
|
+
export function configCacheFromKeyValue(port: KeyValuePort): AiConfigCache {
|
|
47
|
+
return {
|
|
48
|
+
async get(key) {
|
|
49
|
+
return port.getString(key) ?? null;
|
|
50
|
+
},
|
|
51
|
+
async set(key, value) {
|
|
52
|
+
port.set(key, value);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
export type BehaviorState = 'unloaded' | 'ready-network' | 'ready-cache' | 'unavailable';
|
|
39
58
|
|
|
59
|
+
/** The newest document layout this release understands. */
|
|
60
|
+
export const SUPPORTED_SCHEMA_VERSION = 1;
|
|
61
|
+
|
|
62
|
+
/** Bounds on the remote-configured recovery, so a bad document cannot stall a chat. */
|
|
63
|
+
const MAX_SILENT_RETRIES = 5;
|
|
64
|
+
const MIN_REFRESH_TTL_SECONDS = 30;
|
|
65
|
+
const DEFAULT_REFRESH_TTL_SECONDS = 21_600;
|
|
66
|
+
|
|
40
67
|
const numbers = (value: unknown): number[] | undefined =>
|
|
41
68
|
Array.isArray(value) ? value.filter((n): n is number => Number.isFinite(n)) : undefined;
|
|
42
69
|
|
|
43
|
-
|
|
44
|
-
|
|
70
|
+
const positiveInt = (value: unknown, fallback: number): number =>
|
|
71
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
72
|
+
|
|
73
|
+
function invalid(reason: string): never {
|
|
74
|
+
throw new AiError('config-unavailable', `behavior config ${reason}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** https, or http on a development machine: a bearer token travels to this host. */
|
|
78
|
+
export function isTrustedUrl(url: string): boolean {
|
|
79
|
+
if (/^https:\/\//i.test(url)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(url);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hostOf(url: string): string {
|
|
86
|
+
return url
|
|
87
|
+
.replace(/^[a-z]+:\/\//i, '')
|
|
88
|
+
.split(/[/:?#]/)[0]!
|
|
89
|
+
.toLowerCase();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ParseBehaviorConfigOptions {
|
|
93
|
+
/** The app this document must be for; a document for another app is refused. */
|
|
94
|
+
expectedAppId?: string;
|
|
95
|
+
/** Hosts `api.baseUrl` may name. Any host is accepted when omitted. */
|
|
96
|
+
allowedHosts?: readonly string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Parse + validate; unknown fields ignored (forward compatibility).
|
|
101
|
+
*
|
|
102
|
+
* Every field this package acts on is checked here, because the document is
|
|
103
|
+
* remote and the alternative is a TypeError inside an XHR callback, a revalidate
|
|
104
|
+
* on every single request, or a bearer token sent to a host nobody vetted
|
|
105
|
+
* (AI-06, AI-07).
|
|
106
|
+
*/
|
|
107
|
+
export function parseBehaviorConfig(
|
|
108
|
+
json: string,
|
|
109
|
+
options: ParseBehaviorConfigOptions = {},
|
|
110
|
+
): AppBehaviorConfig {
|
|
45
111
|
let raw: Record<string, unknown>;
|
|
46
112
|
try {
|
|
47
113
|
raw = JSON.parse(json) as Record<string, unknown>;
|
|
48
114
|
} catch {
|
|
49
|
-
|
|
115
|
+
invalid('is not valid JSON');
|
|
50
116
|
}
|
|
51
117
|
const api = raw.api as Partial<AppBehaviorConfig['api']> | undefined;
|
|
52
118
|
if (
|
|
@@ -54,17 +120,42 @@ export function parseBehaviorConfig(json: string): AppBehaviorConfig {
|
|
|
54
120
|
typeof api?.baseUrl !== 'string' ||
|
|
55
121
|
!Array.isArray(api.scopes)
|
|
56
122
|
) {
|
|
57
|
-
|
|
123
|
+
invalid('is missing required fields');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const schemaVersion = positiveInt(raw.schemaVersion, 1);
|
|
127
|
+
if (schemaVersion > SUPPORTED_SCHEMA_VERSION) {
|
|
128
|
+
// A newer layout may mean something different by the same field names.
|
|
129
|
+
invalid(`declares schema version ${schemaVersion}, newer than ${SUPPORTED_SCHEMA_VERSION}`);
|
|
130
|
+
}
|
|
131
|
+
if (options.expectedAppId !== undefined && raw.appId !== options.expectedAppId) {
|
|
132
|
+
// The cache key and the request path carry the app id; a document for
|
|
133
|
+
// another app would send this app's traffic somewhere else entirely.
|
|
134
|
+
invalid(`is for another app (${raw.appId})`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const baseUrl = api.baseUrl.replace(/\/$/, '');
|
|
138
|
+
if (!isTrustedUrl(baseUrl)) {
|
|
139
|
+
invalid('names a baseUrl that is not https');
|
|
58
140
|
}
|
|
141
|
+
if (options.allowedHosts && !options.allowedHosts.includes(hostOf(baseUrl))) {
|
|
142
|
+
invalid(`names a baseUrl outside the allowed hosts (${hostOf(baseUrl)})`);
|
|
143
|
+
}
|
|
144
|
+
|
|
59
145
|
const recovery = (raw.recovery ?? {}) as Partial<AppBehaviorConfig['recovery']>;
|
|
60
146
|
const features = (raw.features ?? {}) as Partial<AppBehaviorConfig['features']>;
|
|
61
147
|
return {
|
|
62
|
-
schemaVersion
|
|
148
|
+
schemaVersion,
|
|
63
149
|
appId: raw.appId,
|
|
64
|
-
configVersion:
|
|
65
|
-
|
|
150
|
+
configVersion: positiveInt(raw.configVersion, 0),
|
|
151
|
+
// A TTL that was NaN made `isFresh` false forever, so every request
|
|
152
|
+
// revalidated over the network (AI-06).
|
|
153
|
+
refreshTtlSeconds: Math.max(
|
|
154
|
+
MIN_REFRESH_TTL_SECONDS,
|
|
155
|
+
positiveInt(raw.refreshTtlSeconds, DEFAULT_REFRESH_TTL_SECONDS),
|
|
156
|
+
),
|
|
66
157
|
api: {
|
|
67
|
-
baseUrl
|
|
158
|
+
baseUrl,
|
|
68
159
|
scopes: api.scopes.filter((s): s is string => typeof s === 'string'),
|
|
69
160
|
supportsStreaming: api.supportsStreaming !== false,
|
|
70
161
|
},
|
|
@@ -73,9 +164,12 @@ export function parseBehaviorConfig(json: string): AppBehaviorConfig {
|
|
|
73
164
|
// throw a TypeError inside the XHR error callback, where nothing caught it.
|
|
74
165
|
recovery: {
|
|
75
166
|
reloadOnHttpStatus: numbers(recovery.reloadOnHttpStatus) ?? [404, 410],
|
|
76
|
-
maxSilentRetries:
|
|
77
|
-
|
|
78
|
-
|
|
167
|
+
maxSilentRetries: Math.min(
|
|
168
|
+
MAX_SILENT_RETRIES,
|
|
169
|
+
Number.isFinite(recovery.maxSilentRetries)
|
|
170
|
+
? Math.max(0, Number(recovery.maxSilentRetries))
|
|
171
|
+
: 1,
|
|
172
|
+
),
|
|
79
173
|
retryBackoffMs: numbers(recovery.retryBackoffMs) ?? [500, 2000],
|
|
80
174
|
},
|
|
81
175
|
features: { aiEnabled: features.aiEnabled !== false },
|
|
@@ -96,6 +190,8 @@ export interface BehaviorConfigDeps {
|
|
|
96
190
|
now?: () => number;
|
|
97
191
|
/** Limit on one config fetch, body included. Defaults to 10 s. */
|
|
98
192
|
timeoutMs?: number;
|
|
193
|
+
/** Hosts the document's `api.baseUrl` may name. */
|
|
194
|
+
allowedHosts?: readonly string[];
|
|
99
195
|
}
|
|
100
196
|
|
|
101
197
|
/**
|
|
@@ -113,7 +209,7 @@ export class BehaviorConfigManager {
|
|
|
113
209
|
* chat and a quota call together, and each used to fetch the config itself.
|
|
114
210
|
*/
|
|
115
211
|
private readonly cacheRead = createSingleFlight<void>();
|
|
116
|
-
private readonly networkFetch = createSingleFlight<AppBehaviorConfig
|
|
212
|
+
private readonly networkFetch = createSingleFlight<AppBehaviorConfig>();
|
|
117
213
|
private currentState: BehaviorState = 'unloaded';
|
|
118
214
|
private readonly stateListeners = createListenerSet<[]>();
|
|
119
215
|
|
|
@@ -134,12 +230,24 @@ export class BehaviorConfigManager {
|
|
|
134
230
|
}
|
|
135
231
|
|
|
136
232
|
constructor(private deps: BehaviorConfigDeps) {
|
|
233
|
+
if (!isTrustedUrl(deps.configUrl)) {
|
|
234
|
+
// The document decides where the tokens go, so where it comes from
|
|
235
|
+
// matters as much (AI-07).
|
|
236
|
+
throw new AiError('config-unavailable', 'configUrl must be https');
|
|
237
|
+
}
|
|
137
238
|
this.cacheKey = `rn-ai:config:${deps.appId}`;
|
|
138
239
|
this.cache = deps.cache ?? new MemoryConfigCache();
|
|
139
240
|
this.fetchFn = deps.fetchFn ?? fetch;
|
|
140
241
|
this.now = deps.now ?? Date.now;
|
|
141
242
|
}
|
|
142
243
|
|
|
244
|
+
private parse(json: string): AppBehaviorConfig {
|
|
245
|
+
return parseBehaviorConfig(json, {
|
|
246
|
+
expectedAppId: this.deps.appId,
|
|
247
|
+
...(this.deps.allowedHosts ? { allowedHosts: this.deps.allowedHosts } : {}),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
143
251
|
private isFresh(envelope: CacheEnvelope, config: AppBehaviorConfig): boolean {
|
|
144
252
|
return this.now() - envelope.fetchedAt < config.refreshTtlSeconds * 1000;
|
|
145
253
|
}
|
|
@@ -154,44 +262,62 @@ export class BehaviorConfigManager {
|
|
|
154
262
|
if (!stored) return;
|
|
155
263
|
try {
|
|
156
264
|
const envelope = JSON.parse(stored) as CacheEnvelope;
|
|
157
|
-
this.current = { config:
|
|
265
|
+
this.current = { config: this.parse(envelope.configJson), envelope };
|
|
158
266
|
this.setState('ready-cache');
|
|
159
267
|
} catch {
|
|
160
268
|
// corrupted cache entries are ignored, not fatal
|
|
161
269
|
}
|
|
162
270
|
}
|
|
163
271
|
|
|
164
|
-
private fetchNetwork(): Promise<AppBehaviorConfig
|
|
272
|
+
private fetchNetwork(): Promise<AppBehaviorConfig> {
|
|
165
273
|
return this.networkFetch.run(() => this.fetchFromNetwork());
|
|
166
274
|
}
|
|
167
275
|
|
|
168
|
-
|
|
276
|
+
/** One attempt; `conditional` sends the stored ETag when there is one. */
|
|
277
|
+
private async fetchOnce(
|
|
278
|
+
conditional: boolean,
|
|
279
|
+
): Promise<{ status: number; text: string; etag?: string }> {
|
|
169
280
|
const headers: Record<string, string> = {};
|
|
170
|
-
|
|
171
|
-
|
|
281
|
+
const etag = this.current?.envelope.etag;
|
|
282
|
+
if (conditional && etag) {
|
|
283
|
+
headers['If-None-Match'] = etag;
|
|
172
284
|
}
|
|
173
285
|
// Bounded: with no cache, a hung fetch used to block every chat and quota call.
|
|
174
|
-
|
|
286
|
+
return withTimeout(
|
|
175
287
|
this.deps.timeoutMs ?? DEFAULT_AI_TIMEOUTS.configMs,
|
|
176
288
|
async signal => {
|
|
177
|
-
const
|
|
178
|
-
return {
|
|
289
|
+
const response = await this.fetchFn(this.deps.configUrl, { headers, signal });
|
|
290
|
+
return {
|
|
291
|
+
status: response.status,
|
|
292
|
+
text: response.ok && response.status !== 304 ? await response.text() : '',
|
|
293
|
+
etag: response.headers.get('etag') ?? undefined,
|
|
294
|
+
};
|
|
179
295
|
},
|
|
180
296
|
() => new AiError('config-unavailable', 'config fetch timed out'),
|
|
181
297
|
);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async fetchFromNetwork(): Promise<AppBehaviorConfig> {
|
|
301
|
+
let result = await this.fetchOnce(true);
|
|
302
|
+
if (result.status === 304) {
|
|
303
|
+
if (this.current) {
|
|
304
|
+
this.current.envelope.fetchedAt = this.now();
|
|
305
|
+
await this.persist(this.current.envelope);
|
|
306
|
+
this.setState('ready-network');
|
|
307
|
+
return this.current.config;
|
|
308
|
+
}
|
|
309
|
+
// Not-modified against a cache that is no longer there (it was cleared,
|
|
310
|
+
// or it failed to parse). Asking again without the ETag is the answer;
|
|
311
|
+
// reporting `config fetch failed: 304` was not (AI-17).
|
|
312
|
+
result = await this.fetchOnce(false);
|
|
187
313
|
}
|
|
188
|
-
if (
|
|
189
|
-
throw new AiError('config-unavailable', `config fetch failed: ${
|
|
314
|
+
if (result.status < 200 || result.status >= 300) {
|
|
315
|
+
throw new AiError('config-unavailable', `config fetch failed: ${result.status}`);
|
|
190
316
|
}
|
|
191
|
-
const config =
|
|
317
|
+
const config = this.parse(result.text);
|
|
192
318
|
const envelope: CacheEnvelope = {
|
|
193
|
-
configJson,
|
|
194
|
-
etag:
|
|
319
|
+
configJson: result.text,
|
|
320
|
+
etag: result.etag,
|
|
195
321
|
fetchedAt: this.now(),
|
|
196
322
|
};
|
|
197
323
|
this.current = { config, envelope };
|
|
@@ -218,16 +344,11 @@ export class BehaviorConfigManager {
|
|
|
218
344
|
return this.current.config;
|
|
219
345
|
}
|
|
220
346
|
try {
|
|
221
|
-
|
|
222
|
-
if (fetched) return fetched;
|
|
347
|
+
return await this.fetchNetwork();
|
|
223
348
|
} catch (e) {
|
|
224
349
|
if (this.current) return this.current.config; // stale beats broken
|
|
225
350
|
this.setState('unavailable');
|
|
226
351
|
throw e instanceof AiError ? e : new AiError('config-unavailable', String(e));
|
|
227
352
|
}
|
|
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
353
|
}
|
|
233
354
|
}
|