@chipmobilesdk/rn-ai 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,75 +1,74 @@
1
- /** Typed client errors (contracts/rn-ai-public-api.md). */
2
- export type AiErrorCode =
3
- | 'auth-invalid'
4
- | 'not-entitled'
5
- | 'user-blocked'
6
- | 'app-unknown'
7
- | 'rate-limited'
8
- | 'upstream-busy'
9
- | 'payload-too-large'
10
- | 'modality-not-allowed'
11
- | 'config-unavailable'
12
- | 'upstream-error'
13
- | 'internal';
14
-
15
- export class AiError extends Error {
16
- readonly code: AiErrorCode;
17
- readonly retryAfterSeconds?: number;
18
- readonly recoverable: boolean;
19
-
20
- constructor(
21
- code: AiErrorCode,
22
- message: string,
23
- options: { retryAfterSeconds?: number; recoverable?: boolean } = {},
24
- ) {
25
- super(message);
26
- this.name = 'AiError';
27
- this.code = code;
28
- this.retryAfterSeconds = options.retryAfterSeconds;
29
- this.recoverable =
30
- options.recoverable ?? (code === 'rate-limited' || code === 'upstream-busy');
31
- }
32
- }
33
-
34
- export function isAiError(value: unknown): value is AiError {
35
- return value instanceof AiError;
36
- }
37
-
38
- const HTTP_CODE_MAP: Record<string, AiErrorCode> = {
39
- AUTH_INVALID: 'auth-invalid',
40
- NOT_ENTITLED: 'not-entitled',
41
- USER_BLOCKED: 'user-blocked',
42
- APP_UNKNOWN: 'app-unknown',
43
- PAYLOAD_TOO_LARGE: 'payload-too-large',
44
- MODALITY_NOT_ALLOWED: 'modality-not-allowed',
45
- RATE_LIMITED: 'rate-limited',
46
- UPSTREAM_BUSY: 'upstream-busy',
47
- UPSTREAM_ERROR: 'upstream-error',
48
- };
49
-
50
- /** Map a gateway error response (status + JSON body) to a typed AiError. */
51
- export function errorFromHttp(status: number, bodyText: string): AiError {
52
- let code: AiErrorCode = status >= 500 ? 'upstream-error' : 'internal';
53
- let message = `gateway responded ${status}`;
54
- let retryAfterSeconds: number | undefined;
55
- try {
56
- const parsed = JSON.parse(bodyText) as {
57
- error?: { code?: string; message?: string; retryAfterSeconds?: number };
58
- };
59
- if (parsed.error?.code && HTTP_CODE_MAP[parsed.error.code]) {
60
- code = HTTP_CODE_MAP[parsed.error.code];
61
- }
62
- if (parsed.error?.message) message = parsed.error.message;
63
- retryAfterSeconds = parsed.error?.retryAfterSeconds;
64
- } catch {
65
- if (status === 0) {
66
- code = 'upstream-error';
67
- message = 'network failure';
68
- }
69
- }
70
- // Status 0 is a dropped connection, which a retry may well fix.
71
- return new AiError(code, message, {
72
- retryAfterSeconds,
73
- recoverable: status === 0 ? true : undefined,
74
- });
75
- }
1
+ /** Typed client errors (contracts/rn-ai-public-api.md). */
2
+ export type AiErrorCode =
3
+ | 'auth-invalid'
4
+ | 'not-entitled'
5
+ | 'user-blocked'
6
+ | 'app-unknown'
7
+ | 'rate-limited'
8
+ | 'upstream-busy'
9
+ | 'payload-too-large'
10
+ | 'modality-not-allowed'
11
+ | 'config-unavailable'
12
+ | 'upstream-error'
13
+ | 'internal';
14
+
15
+ export class AiError extends Error {
16
+ readonly code: AiErrorCode;
17
+ readonly retryAfterSeconds?: number;
18
+ readonly recoverable: boolean;
19
+
20
+ constructor(
21
+ code: AiErrorCode,
22
+ message: string,
23
+ options: { retryAfterSeconds?: number; recoverable?: boolean } = {},
24
+ ) {
25
+ super(message);
26
+ this.name = 'AiError';
27
+ this.code = code;
28
+ this.retryAfterSeconds = options.retryAfterSeconds;
29
+ this.recoverable = options.recoverable ?? (code === 'rate-limited' || code === 'upstream-busy');
30
+ }
31
+ }
32
+
33
+ export function isAiError(value: unknown): value is AiError {
34
+ return value instanceof AiError;
35
+ }
36
+
37
+ const HTTP_CODE_MAP: Record<string, AiErrorCode> = {
38
+ AUTH_INVALID: 'auth-invalid',
39
+ NOT_ENTITLED: 'not-entitled',
40
+ USER_BLOCKED: 'user-blocked',
41
+ APP_UNKNOWN: 'app-unknown',
42
+ PAYLOAD_TOO_LARGE: 'payload-too-large',
43
+ MODALITY_NOT_ALLOWED: 'modality-not-allowed',
44
+ RATE_LIMITED: 'rate-limited',
45
+ UPSTREAM_BUSY: 'upstream-busy',
46
+ UPSTREAM_ERROR: 'upstream-error',
47
+ };
48
+
49
+ /** Map a gateway error response (status + JSON body) to a typed AiError. */
50
+ export function errorFromHttp(status: number, bodyText: string): AiError {
51
+ let code: AiErrorCode = status >= 500 ? 'upstream-error' : 'internal';
52
+ let message = `gateway responded ${status}`;
53
+ let retryAfterSeconds: number | undefined;
54
+ try {
55
+ const parsed = JSON.parse(bodyText) as {
56
+ error?: { code?: string; message?: string; retryAfterSeconds?: number };
57
+ };
58
+ if (parsed.error?.code && HTTP_CODE_MAP[parsed.error.code]) {
59
+ code = HTTP_CODE_MAP[parsed.error.code];
60
+ }
61
+ if (parsed.error?.message) message = parsed.error.message;
62
+ retryAfterSeconds = parsed.error?.retryAfterSeconds;
63
+ } catch {
64
+ if (status === 0) {
65
+ code = 'upstream-error';
66
+ message = 'network failure';
67
+ }
68
+ }
69
+ // Status 0 is a dropped connection, which a retry may well fix.
70
+ return new AiError(code, message, {
71
+ retryAfterSeconds,
72
+ recoverable: status === 0 ? true : undefined,
73
+ });
74
+ }
@@ -1,27 +1,27 @@
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.
4
- */
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;
22
-
23
- export type AiMessageKey = keyof typeof aiMessages;
24
-
25
- export function aiMessage(key: AiMessageKey): string {
26
- return aiMessages[key];
27
- }
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.
4
+ */
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;
22
+
23
+ export type AiMessageKey = keyof typeof aiMessages;
24
+
25
+ export function aiMessage(key: AiMessageKey): string {
26
+ return aiMessages[key];
27
+ }
package/src/index.ts CHANGED
@@ -1,35 +1,31 @@
1
- /**
2
- * @chipmobilesdk/rn-ai — AI gateway client for ChipMobileSdk-family React
3
- * Native apps (feature 005). Single public entrypoint (LIB-003); modules
4
- * under `config/`, `transport/`, `core/`, `react/`, and `diagnostics/` are
5
- * private. Contract: specs/005-azure-ai-gateway/contracts/rn-ai-public-api.md
6
- */
7
- export { createAiClient } from './core/aiClient';
8
- export type {
9
- AiClient,
10
- AiClientConfig,
11
- AiBehaviorState,
12
- TokenProvider,
13
- ChatRequest,
14
- ChatMessage,
15
- ContentPart,
16
- ChatStreamHandlers,
17
- ChatHandle,
18
- Usage,
19
- QuotaSnapshot,
20
- } from './core/aiClient';
21
-
22
- export type {
23
- AppBehaviorConfig,
24
- AiConfigCache,
25
- BehaviorState,
26
- } from './config/behaviorConfig';
27
-
28
- export type { AiTimeouts } from './transport/timeouts';
29
- export { useAiChat, useAiQuota } from './react/hooks';
30
- export type { AiChatStatus, UseAiChatResult, UseAiQuotaResult } from './react/hooks';
31
-
32
- export { AiError, isAiError } from './diagnostics/errors';
33
- export type { AiErrorCode } from './diagnostics/errors';
34
- export { aiMessages, aiMessage } from './diagnostics/messageKeys';
35
- export type { AiMessageKey } from './diagnostics/messageKeys';
1
+ /**
2
+ * @chipmobilesdk/rn-ai — AI gateway client for ChipMobileSdk-family React
3
+ * Native apps (feature 005). Single public entrypoint (LIB-003); modules
4
+ * under `config/`, `transport/`, `core/`, `react/`, and `diagnostics/` are
5
+ * private. Contract: specs/005-azure-ai-gateway/contracts/rn-ai-public-api.md
6
+ */
7
+ export { createAiClient } from './core/aiClient';
8
+ export type {
9
+ AiClient,
10
+ AiClientConfig,
11
+ AiBehaviorState,
12
+ TokenProvider,
13
+ ChatRequest,
14
+ ChatMessage,
15
+ ContentPart,
16
+ ChatStreamHandlers,
17
+ ChatHandle,
18
+ Usage,
19
+ QuotaSnapshot,
20
+ } from './core/aiClient';
21
+
22
+ export type { AppBehaviorConfig, AiConfigCache, BehaviorState } from './config/behaviorConfig';
23
+
24
+ export type { AiTimeouts } from './transport/timeouts';
25
+ export { useAiChat, useAiQuota } from './react/hooks';
26
+ export type { AiChatStatus, UseAiChatResult, UseAiQuotaResult } from './react/hooks';
27
+
28
+ export { AiError, isAiError } from './diagnostics/errors';
29
+ export type { AiErrorCode } from './diagnostics/errors';
30
+ export { aiMessages, aiMessage } from './diagnostics/messageKeys';
31
+ export type { AiMessageKey } from './diagnostics/messageKeys';
@@ -1,134 +1,123 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react';
2
- import type {
3
- AiClient,
4
- ChatHandle,
5
- ChatRequest,
6
- QuotaSnapshot,
7
- } from '../core/aiClient';
8
- import type { AiError } from '../diagnostics/errors';
9
-
10
- export type AiChatStatus =
11
- | 'idle'
12
- | 'loading'
13
- | 'streaming'
14
- | 'done'
15
- | 'error'
16
- | 'rate-limited';
17
-
18
- export interface UseAiChatResult {
19
- send(request: ChatRequest): void;
20
- cancel(): void;
21
- status: AiChatStatus;
22
- /** Accumulated streamed text (appends incrementally — PERF-002). */
23
- text: string;
24
- error?: AiError;
25
- retryAt?: Date;
26
- quota?: QuotaSnapshot;
27
- }
28
-
29
- export function useAiChat(client: AiClient): UseAiChatResult {
30
- const [status, setStatus] = useState<AiChatStatus>('idle');
31
- const [text, setText] = useState('');
32
- const [error, setError] = useState<AiError | undefined>();
33
- const [retryAt, setRetryAt] = useState<Date | undefined>();
34
- const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
35
- const handleRef = useRef<ChatHandle | null>(null);
36
- // Bumped by every send, cancel and unmount. A callback from an older request
37
- // is dropped, so it can never overwrite the state of the one on screen —
38
- // defence in depth behind the client's own cancel guard.
39
- const requestRef = useRef(0);
40
-
41
- const send = useCallback(
42
- (request: ChatRequest) => {
43
- handleRef.current?.cancel();
44
- const id = ++requestRef.current;
45
- const current = () => requestRef.current === id;
46
- setText('');
47
- setError(undefined);
48
- setRetryAt(undefined);
49
- setStatus('loading');
50
- handleRef.current = client.chat(request, {
51
- onDelta: delta => {
52
- if (!current()) return;
53
- setStatus('streaming');
54
- setText(previous => previous + delta);
55
- },
56
- onDone: result => {
57
- if (!current()) return;
58
- if (result.quota) setQuota(result.quota);
59
- setStatus('done');
60
- },
61
- onError: aiError => {
62
- if (!current()) return;
63
- setError(aiError);
64
- if (aiError.code === 'rate-limited') {
65
- setStatus('rate-limited');
66
- if (aiError.retryAfterSeconds !== undefined) {
67
- setRetryAt(new Date(Date.now() + aiError.retryAfterSeconds * 1000));
68
- }
69
- } else {
70
- setStatus('error');
71
- }
72
- },
73
- });
74
- },
75
- [client],
76
- );
77
-
78
- const cancel = useCallback(() => {
79
- requestRef.current += 1;
80
- handleRef.current?.cancel();
81
- setStatus('idle');
82
- }, []);
83
-
84
- useEffect(
85
- () => () => {
86
- requestRef.current += 1;
87
- handleRef.current?.cancel();
88
- },
89
- [],
90
- );
91
-
92
- return { send, cancel, status, text, error, retryAt, quota };
93
- }
94
-
95
- export interface UseAiQuotaResult {
96
- quota?: QuotaSnapshot;
97
- status: 'loading' | 'ready' | 'error';
98
- refresh(): void;
99
- }
100
-
101
- export function useAiQuota(client: AiClient): UseAiQuotaResult {
102
- const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
103
- const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
104
- const alive = useRef(true);
105
- // Only the latest refresh may settle the state; an older, slower one that
106
- // lands afterwards is stale.
107
- const latest = useRef(0);
108
-
109
- const refresh = useCallback(() => {
110
- const id = ++latest.current;
111
- const current = () => alive.current && latest.current === id;
112
- setStatus('loading');
113
- client
114
- .getQuota()
115
- .then(snapshot => {
116
- if (!current()) return;
117
- setQuota(snapshot);
118
- setStatus('ready');
119
- })
120
- .catch(() => {
121
- if (current()) setStatus('error');
122
- });
123
- }, [client]);
124
-
125
- useEffect(() => {
126
- alive.current = true;
127
- refresh();
128
- return () => {
129
- alive.current = false;
130
- };
131
- }, [refresh]);
132
-
133
- return { quota, status, refresh };
134
- }
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { AiClient, ChatHandle, ChatRequest, QuotaSnapshot } from '../core/aiClient';
3
+ import type { AiError } from '../diagnostics/errors';
4
+
5
+ export type AiChatStatus = 'idle' | 'loading' | 'streaming' | 'done' | 'error' | 'rate-limited';
6
+
7
+ export interface UseAiChatResult {
8
+ send(request: ChatRequest): void;
9
+ cancel(): void;
10
+ status: AiChatStatus;
11
+ /** Accumulated streamed text (appends incrementally — PERF-002). */
12
+ text: string;
13
+ error?: AiError;
14
+ retryAt?: Date;
15
+ quota?: QuotaSnapshot;
16
+ }
17
+
18
+ export function useAiChat(client: AiClient): UseAiChatResult {
19
+ const [status, setStatus] = useState<AiChatStatus>('idle');
20
+ const [text, setText] = useState('');
21
+ const [error, setError] = useState<AiError | undefined>();
22
+ const [retryAt, setRetryAt] = useState<Date | undefined>();
23
+ const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
24
+ const handleRef = useRef<ChatHandle | null>(null);
25
+ // Bumped by every send, cancel and unmount. A callback from an older request
26
+ // is dropped, so it can never overwrite the state of the one on screen —
27
+ // defence in depth behind the client's own cancel guard.
28
+ const requestRef = useRef(0);
29
+
30
+ const send = useCallback(
31
+ (request: ChatRequest) => {
32
+ handleRef.current?.cancel();
33
+ const id = ++requestRef.current;
34
+ const current = () => requestRef.current === id;
35
+ setText('');
36
+ setError(undefined);
37
+ setRetryAt(undefined);
38
+ setStatus('loading');
39
+ handleRef.current = client.chat(request, {
40
+ onDelta: delta => {
41
+ if (!current()) return;
42
+ setStatus('streaming');
43
+ setText(previous => previous + delta);
44
+ },
45
+ onDone: result => {
46
+ if (!current()) return;
47
+ if (result.quota) setQuota(result.quota);
48
+ setStatus('done');
49
+ },
50
+ onError: aiError => {
51
+ if (!current()) return;
52
+ setError(aiError);
53
+ if (aiError.code === 'rate-limited') {
54
+ setStatus('rate-limited');
55
+ if (aiError.retryAfterSeconds !== undefined) {
56
+ setRetryAt(new Date(Date.now() + aiError.retryAfterSeconds * 1000));
57
+ }
58
+ } else {
59
+ setStatus('error');
60
+ }
61
+ },
62
+ });
63
+ },
64
+ [client],
65
+ );
66
+
67
+ const cancel = useCallback(() => {
68
+ requestRef.current += 1;
69
+ handleRef.current?.cancel();
70
+ setStatus('idle');
71
+ }, []);
72
+
73
+ useEffect(
74
+ () => () => {
75
+ requestRef.current += 1;
76
+ handleRef.current?.cancel();
77
+ },
78
+ [],
79
+ );
80
+
81
+ return { send, cancel, status, text, error, retryAt, quota };
82
+ }
83
+
84
+ export interface UseAiQuotaResult {
85
+ quota?: QuotaSnapshot;
86
+ status: 'loading' | 'ready' | 'error';
87
+ refresh(): void;
88
+ }
89
+
90
+ export function useAiQuota(client: AiClient): UseAiQuotaResult {
91
+ const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
92
+ const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
93
+ const alive = useRef(true);
94
+ // Only the latest refresh may settle the state; an older, slower one that
95
+ // lands afterwards is stale.
96
+ const latest = useRef(0);
97
+
98
+ const refresh = useCallback(() => {
99
+ const id = ++latest.current;
100
+ const current = () => alive.current && latest.current === id;
101
+ setStatus('loading');
102
+ client
103
+ .getQuota()
104
+ .then(snapshot => {
105
+ if (!current()) return;
106
+ setQuota(snapshot);
107
+ setStatus('ready');
108
+ })
109
+ .catch(() => {
110
+ if (current()) setStatus('error');
111
+ });
112
+ }, [client]);
113
+
114
+ useEffect(() => {
115
+ alive.current = true;
116
+ refresh();
117
+ return () => {
118
+ alive.current = false;
119
+ };
120
+ }, [refresh]);
121
+
122
+ return { quota, status, refresh };
123
+ }