@chipmobilesdk/rn-ai 0.2.1 → 0.4.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 +113 -0
- package/README.md +72 -26
- package/package.json +5 -2
- package/skills/sdk-rn-ai/SKILL.md +35 -4
- package/src/config/behaviorConfig.ts +193 -39
- package/src/contract/gateway.ts +91 -0
- package/src/core/aiClient.ts +260 -86
- package/src/core/quotaRequest.ts +125 -0
- package/src/diagnostics/errors.ts +108 -31
- package/src/diagnostics/messageKeys.ts +58 -19
- package/src/index.ts +30 -3
- package/src/react/hooks.ts +102 -12
- package/src/transport/sseClient.ts +58 -11
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
/** Typed client errors (contracts/rn-ai-public-api.md). */
|
|
2
|
+
import { SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
RETRY_AFTER_HEADER,
|
|
6
|
+
type GatewayErrorBody,
|
|
7
|
+
type GatewayErrorCode,
|
|
8
|
+
} from '../contract/gateway';
|
|
9
|
+
|
|
2
10
|
export type AiErrorCode =
|
|
3
11
|
| 'auth-invalid'
|
|
12
|
+
/** The session itself ended: the user has to sign in again. */
|
|
13
|
+
| 'session-expired'
|
|
4
14
|
| 'not-entitled'
|
|
5
15
|
| 'user-blocked'
|
|
6
16
|
| 'app-unknown'
|
|
@@ -9,24 +19,50 @@ export type AiErrorCode =
|
|
|
9
19
|
| 'payload-too-large'
|
|
10
20
|
| 'modality-not-allowed'
|
|
11
21
|
| 'config-unavailable'
|
|
22
|
+
/** The request never reached the gateway: no connection, a timeout, a dropped stream. */
|
|
23
|
+
| 'network'
|
|
12
24
|
| 'upstream-error'
|
|
13
25
|
| 'internal';
|
|
14
26
|
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
export interface AiErrorOptions {
|
|
28
|
+
retryAfterSeconds?: number | undefined;
|
|
29
|
+
/** Whether retrying can succeed. Defaults by code; see `RETRYABLE_BY_DEFAULT`. */
|
|
30
|
+
retryable?: boolean | undefined;
|
|
31
|
+
/** @deprecated Use `retryable`; accepted as its alias. */
|
|
32
|
+
recoverable?: boolean | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The code the layer underneath reported — an rn-auth `AuthErrorCode`, or the
|
|
35
|
+
* gateway's own code. Kept so a screen can say *why* rather than only *what*.
|
|
36
|
+
*/
|
|
37
|
+
cause?: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Codes a retry can fix on its own. */
|
|
41
|
+
const RETRYABLE_BY_DEFAULT: ReadonlySet<AiErrorCode> = new Set<AiErrorCode>([
|
|
42
|
+
'rate-limited',
|
|
43
|
+
'upstream-busy',
|
|
44
|
+
'network',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-ai'`. */
|
|
48
|
+
export class AiError extends SdkError<AiErrorCode> {
|
|
49
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
50
|
+
/** The underlying code, when one layer down had a name for the failure. */
|
|
51
|
+
readonly cause?: string | undefined;
|
|
19
52
|
|
|
20
|
-
constructor(
|
|
21
|
-
code
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
super(message);
|
|
53
|
+
constructor(code: AiErrorCode, message: string, options: AiErrorOptions = {}) {
|
|
54
|
+
super('rn-ai', code, {
|
|
55
|
+
message,
|
|
56
|
+
retryable: options.retryable ?? options.recoverable ?? RETRYABLE_BY_DEFAULT.has(code),
|
|
57
|
+
});
|
|
26
58
|
this.name = 'AiError';
|
|
27
|
-
this.code = code;
|
|
28
59
|
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
29
|
-
this.
|
|
60
|
+
this.cause = options.cause;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @deprecated Use `retryable`, the name every SDK package shares. */
|
|
64
|
+
get recoverable(): boolean {
|
|
65
|
+
return this.retryable;
|
|
30
66
|
}
|
|
31
67
|
}
|
|
32
68
|
|
|
@@ -34,41 +70,82 @@ export function isAiError(value: unknown): value is AiError {
|
|
|
34
70
|
return value instanceof AiError;
|
|
35
71
|
}
|
|
36
72
|
|
|
37
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Every gateway code, mapped. Typed as a total record, so a code added to the
|
|
75
|
+
* shared contract does not compile until the client says what it means (GW-13).
|
|
76
|
+
*/
|
|
77
|
+
const HTTP_CODE_MAP: Readonly<Record<GatewayErrorCode, AiErrorCode>> = {
|
|
78
|
+
BAD_REQUEST: 'internal',
|
|
38
79
|
AUTH_INVALID: 'auth-invalid',
|
|
39
80
|
NOT_ENTITLED: 'not-entitled',
|
|
40
81
|
USER_BLOCKED: 'user-blocked',
|
|
41
82
|
APP_UNKNOWN: 'app-unknown',
|
|
83
|
+
// The endpoint moved: the client's configuration is the stale part.
|
|
84
|
+
ENDPOINT_RETIRED: 'config-unavailable',
|
|
42
85
|
PAYLOAD_TOO_LARGE: 'payload-too-large',
|
|
43
86
|
MODALITY_NOT_ALLOWED: 'modality-not-allowed',
|
|
44
87
|
RATE_LIMITED: 'rate-limited',
|
|
45
88
|
UPSTREAM_BUSY: 'upstream-busy',
|
|
46
89
|
UPSTREAM_ERROR: 'upstream-error',
|
|
90
|
+
INTERNAL: 'internal',
|
|
47
91
|
};
|
|
48
92
|
|
|
93
|
+
function isGatewayCode(value: unknown): value is GatewayErrorCode {
|
|
94
|
+
return typeof value === 'string' && value in HTTP_CODE_MAP;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `Retry-After` in seconds, from either form the header may take. */
|
|
98
|
+
export function parseRetryAfter(value: string | null | undefined): number | undefined {
|
|
99
|
+
if (!value) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const seconds = Number(value);
|
|
103
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
104
|
+
return seconds;
|
|
105
|
+
}
|
|
106
|
+
const date = Date.parse(value);
|
|
107
|
+
if (Number.isFinite(date)) {
|
|
108
|
+
return Math.max(0, Math.round((date - Date.now()) / 1000));
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface HttpErrorContext {
|
|
114
|
+
/** The response headers, when the transport can read them. */
|
|
115
|
+
getHeader?: (name: string) => string | null | undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
49
118
|
/** Map a gateway error response (status + JSON body) to a typed AiError. */
|
|
50
|
-
export function errorFromHttp(
|
|
119
|
+
export function errorFromHttp(
|
|
120
|
+
status: number,
|
|
121
|
+
bodyText: string,
|
|
122
|
+
context: HttpErrorContext = {},
|
|
123
|
+
): AiError {
|
|
124
|
+
// Status 0 is not an answer at all: the request never arrived, or the
|
|
125
|
+
// connection dropped. That is a network failure a retry may well fix, not an
|
|
126
|
+
// upstream fault (AI-11).
|
|
127
|
+
if (status === 0) {
|
|
128
|
+
return new AiError(
|
|
129
|
+
'network',
|
|
130
|
+
bodyText === 'timeout' ? 'the request timed out' : 'no connection',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
51
134
|
let code: AiErrorCode = status >= 500 ? 'upstream-error' : 'internal';
|
|
52
135
|
let message = `gateway responded ${status}`;
|
|
53
|
-
let
|
|
136
|
+
let gatewayCode: GatewayErrorCode | undefined;
|
|
137
|
+
let retryAfterSeconds = parseRetryAfter(context.getHeader?.(RETRY_AFTER_HEADER));
|
|
54
138
|
try {
|
|
55
|
-
const parsed = JSON.parse(bodyText) as
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
code = HTTP_CODE_MAP[parsed.error.code];
|
|
139
|
+
const parsed = JSON.parse(bodyText) as Partial<GatewayErrorBody>;
|
|
140
|
+
if (isGatewayCode(parsed.error?.code)) {
|
|
141
|
+
gatewayCode = parsed.error.code;
|
|
142
|
+
code = HTTP_CODE_MAP[gatewayCode];
|
|
60
143
|
}
|
|
61
|
-
|
|
62
|
-
|
|
144
|
+
// The gateway's text can quote upstream detail; keep it readable but shaped.
|
|
145
|
+
if (parsed.error?.message) message = redactPatterns(parsed.error.message);
|
|
146
|
+
retryAfterSeconds = parsed.error?.retryAfterSeconds ?? retryAfterSeconds;
|
|
63
147
|
} catch {
|
|
64
|
-
|
|
65
|
-
code = 'upstream-error';
|
|
66
|
-
message = 'network failure';
|
|
67
|
-
}
|
|
148
|
+
// A body that is not the contract's: the status is all there is to go on.
|
|
68
149
|
}
|
|
69
|
-
|
|
70
|
-
return new AiError(code, message, {
|
|
71
|
-
retryAfterSeconds,
|
|
72
|
-
recoverable: status === 0 ? true : undefined,
|
|
73
|
-
});
|
|
150
|
+
return new AiError(code, message, { retryAfterSeconds, cause: gatewayCode });
|
|
74
151
|
}
|
|
@@ -1,27 +1,66 @@
|
|
|
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.offline': 'No connection. Check your network and try again.',
|
|
31
|
+
'ai.error.generic': 'Something went wrong. Please try again.',
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const aiMessages = aiMessageSet.defaults;
|
|
22
36
|
|
|
23
37
|
export type AiMessageKey = keyof typeof aiMessages;
|
|
24
38
|
|
|
25
39
|
export function aiMessage(key: AiMessageKey): string {
|
|
26
40
|
return aiMessages[key];
|
|
27
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The copy for each error code. A `Record` over the whole code union, so adding a
|
|
45
|
+
* code without choosing its copy fails to compile (AI-16).
|
|
46
|
+
*/
|
|
47
|
+
const ERROR_MESSAGES: Record<AiErrorCode, AiMessageKey> = {
|
|
48
|
+
'auth-invalid': 'ai.error.signIn',
|
|
49
|
+
'session-expired': 'ai.error.signIn',
|
|
50
|
+
network: 'ai.error.offline',
|
|
51
|
+
'not-entitled': 'ai.error.premiumRequired',
|
|
52
|
+
'user-blocked': 'ai.error.blocked',
|
|
53
|
+
'app-unknown': 'ai.error.unavailable',
|
|
54
|
+
'rate-limited': 'ai.error.rateLimited',
|
|
55
|
+
'upstream-busy': 'ai.error.busy',
|
|
56
|
+
'payload-too-large': 'ai.error.payloadTooLarge',
|
|
57
|
+
'modality-not-allowed': 'ai.error.modality',
|
|
58
|
+
'config-unavailable': 'ai.error.unavailable',
|
|
59
|
+
'upstream-error': 'ai.error.generic',
|
|
60
|
+
internal: 'ai.error.generic',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The message key to show a user for an error. */
|
|
64
|
+
export function messageKeyForError(error: AiError): AiMessageKey {
|
|
65
|
+
return ERROR_MESSAGES[error.code];
|
|
66
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type {
|
|
|
10
10
|
AiClientConfig,
|
|
11
11
|
AiBehaviorState,
|
|
12
12
|
TokenProvider,
|
|
13
|
+
AccessTokenRequestOptions,
|
|
13
14
|
ChatRequest,
|
|
14
15
|
ChatMessage,
|
|
15
16
|
ContentPart,
|
|
@@ -20,12 +21,38 @@ export type {
|
|
|
20
21
|
} from './core/aiClient';
|
|
21
22
|
|
|
22
23
|
export type { AppBehaviorConfig, AiConfigCache, BehaviorState } from './config/behaviorConfig';
|
|
24
|
+
export { configCacheFromKeyValue, SUPPORTED_SCHEMA_VERSION } from './config/behaviorConfig';
|
|
25
|
+
|
|
26
|
+
// The gateway contract, shared with the service: error codes, quota headers and
|
|
27
|
+
// stream events have one definition (GW-13).
|
|
28
|
+
export type {
|
|
29
|
+
GatewayErrorCode,
|
|
30
|
+
GatewayErrorBody,
|
|
31
|
+
GatewayQuota,
|
|
32
|
+
GatewayStreamEvent,
|
|
33
|
+
GatewayUsage,
|
|
34
|
+
QuotaHeaderName,
|
|
35
|
+
QuotaHeaderNames,
|
|
36
|
+
} from './contract/gateway';
|
|
37
|
+
export { GATEWAY_ERROR_STATUS, QUOTA_HEADERS, SSE_DONE_SENTINEL } from './contract/gateway';
|
|
23
38
|
|
|
24
39
|
export type { AiTimeouts } from './transport/timeouts';
|
|
25
|
-
export {
|
|
26
|
-
|
|
40
|
+
export type {
|
|
41
|
+
SseRequest,
|
|
42
|
+
SseHandlers,
|
|
43
|
+
SseHandle,
|
|
44
|
+
SseResponseInfo,
|
|
45
|
+
StreamTransport,
|
|
46
|
+
} from './transport/sseClient';
|
|
47
|
+
export { useAiBehaviorState, useAiChat, useAiQuota } from './react/hooks';
|
|
48
|
+
export type {
|
|
49
|
+
AiChatStatus,
|
|
50
|
+
UseAiChatOptions,
|
|
51
|
+
UseAiChatResult,
|
|
52
|
+
UseAiQuotaResult,
|
|
53
|
+
} from './react/hooks';
|
|
27
54
|
|
|
28
55
|
export { AiError, isAiError } from './diagnostics/errors';
|
|
29
56
|
export type { AiErrorCode } from './diagnostics/errors';
|
|
30
|
-
export { aiMessages,
|
|
57
|
+
export { aiMessage, aiMessageSet, aiMessages, messageKeyForError } from './diagnostics/messageKeys';
|
|
31
58
|
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';
|
|
@@ -8,30 +15,74 @@ export interface UseAiChatResult {
|
|
|
8
15
|
send(request: ChatRequest): void;
|
|
9
16
|
cancel(): void;
|
|
10
17
|
status: AiChatStatus;
|
|
11
|
-
/** Accumulated streamed text
|
|
18
|
+
/** Accumulated streamed text, flushed on an interval rather than per token. */
|
|
12
19
|
text: string;
|
|
13
20
|
error?: AiError;
|
|
14
21
|
retryAt?: Date;
|
|
15
22
|
quota?: QuotaSnapshot;
|
|
16
23
|
}
|
|
17
24
|
|
|
18
|
-
export
|
|
25
|
+
export interface UseAiChatOptions {
|
|
26
|
+
/**
|
|
27
|
+
* How often the accumulated text reaches the screen, in milliseconds.
|
|
28
|
+
*
|
|
29
|
+
* A model streams 30–100 tokens a second, and a render per token is 30–100
|
|
30
|
+
* renders of the whole transcript a second on the JS thread (PERF-12). At the
|
|
31
|
+
* default a long answer costs about 20 renders a second instead, and the last
|
|
32
|
+
* tokens are always flushed with the terminal state. `0` renders per token.
|
|
33
|
+
*/
|
|
34
|
+
flushIntervalMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_FLUSH_MS = 50;
|
|
38
|
+
|
|
39
|
+
export function useAiChat(client: AiClient, options: UseAiChatOptions = {}): UseAiChatResult {
|
|
19
40
|
const [status, setStatus] = useState<AiChatStatus>('idle');
|
|
20
41
|
const [text, setText] = useState('');
|
|
21
42
|
const [error, setError] = useState<AiError | undefined>();
|
|
22
43
|
const [retryAt, setRetryAt] = useState<Date | undefined>();
|
|
23
|
-
const
|
|
44
|
+
const fromClient = useAiQuotaValue(client);
|
|
45
|
+
// The figure this chat was told, for a client of the app's own that keeps none.
|
|
46
|
+
const [reported, setReported] = useState<QuotaSnapshot | undefined>();
|
|
47
|
+
const quota = fromClient ?? reported;
|
|
24
48
|
const handleRef = useRef<ChatHandle | null>(null);
|
|
25
49
|
// Bumped by every send, cancel and unmount. A callback from an older request
|
|
26
50
|
// is dropped, so it can never overwrite the state of the one on screen —
|
|
27
51
|
// defence in depth behind the client's own cancel guard.
|
|
28
52
|
const requestRef = useRef(0);
|
|
29
53
|
|
|
54
|
+
// Deltas land here and reach React on a timer, so the transcript renders at a
|
|
55
|
+
// steady rate however fast the model answers.
|
|
56
|
+
const pending = useRef('');
|
|
57
|
+
const flushTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
58
|
+
const flushMs = options.flushIntervalMs ?? DEFAULT_FLUSH_MS;
|
|
59
|
+
|
|
60
|
+
const stopFlushing = useCallback(() => {
|
|
61
|
+
if (flushTimer.current !== undefined) {
|
|
62
|
+
clearTimeout(flushTimer.current);
|
|
63
|
+
flushTimer.current = undefined;
|
|
64
|
+
}
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const flush = useCallback(() => {
|
|
68
|
+
flushTimer.current = undefined;
|
|
69
|
+
const buffered = pending.current;
|
|
70
|
+
if (buffered === '') {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
pending.current = '';
|
|
74
|
+
setText(previous => previous + buffered);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => stopFlushing, [stopFlushing]);
|
|
78
|
+
|
|
30
79
|
const send = useCallback(
|
|
31
80
|
(request: ChatRequest) => {
|
|
32
81
|
handleRef.current?.cancel();
|
|
33
82
|
const id = ++requestRef.current;
|
|
34
83
|
const current = () => requestRef.current === id;
|
|
84
|
+
stopFlushing();
|
|
85
|
+
pending.current = '';
|
|
35
86
|
setText('');
|
|
36
87
|
setError(undefined);
|
|
37
88
|
setRetryAt(undefined);
|
|
@@ -39,16 +90,28 @@ export function useAiChat(client: AiClient): UseAiChatResult {
|
|
|
39
90
|
handleRef.current = client.chat(request, {
|
|
40
91
|
onDelta: delta => {
|
|
41
92
|
if (!current()) return;
|
|
42
|
-
|
|
43
|
-
|
|
93
|
+
pending.current += delta;
|
|
94
|
+
if (flushMs <= 0) {
|
|
95
|
+
flush();
|
|
96
|
+
} else if (flushTimer.current === undefined) {
|
|
97
|
+
flushTimer.current = setTimeout(flush, flushMs);
|
|
98
|
+
}
|
|
99
|
+
// Only the first delta is a state change; the rest are text.
|
|
100
|
+
setStatus(previous => (previous === 'streaming' ? previous : 'streaming'));
|
|
44
101
|
},
|
|
45
102
|
onDone: result => {
|
|
46
103
|
if (!current()) return;
|
|
47
|
-
|
|
104
|
+
stopFlushing();
|
|
105
|
+
flush();
|
|
106
|
+
if (result.quota) setReported(result.quota);
|
|
48
107
|
setStatus('done');
|
|
49
108
|
},
|
|
50
109
|
onError: aiError => {
|
|
51
110
|
if (!current()) return;
|
|
111
|
+
stopFlushing();
|
|
112
|
+
// Whatever arrived before the failure is kept: a half-answer is
|
|
113
|
+
// still what the user was reading.
|
|
114
|
+
flush();
|
|
52
115
|
setError(aiError);
|
|
53
116
|
if (aiError.code === 'rate-limited') {
|
|
54
117
|
setStatus('rate-limited');
|
|
@@ -61,14 +124,15 @@ export function useAiChat(client: AiClient): UseAiChatResult {
|
|
|
61
124
|
},
|
|
62
125
|
});
|
|
63
126
|
},
|
|
64
|
-
[client],
|
|
127
|
+
[client, flush, flushMs, stopFlushing],
|
|
65
128
|
);
|
|
66
129
|
|
|
67
130
|
const cancel = useCallback(() => {
|
|
68
131
|
requestRef.current += 1;
|
|
132
|
+
stopFlushing();
|
|
69
133
|
handleRef.current?.cancel();
|
|
70
134
|
setStatus('idle');
|
|
71
|
-
}, []);
|
|
135
|
+
}, [stopFlushing]);
|
|
72
136
|
|
|
73
137
|
useEffect(
|
|
74
138
|
() => () => {
|
|
@@ -87,8 +151,25 @@ export interface UseAiQuotaResult {
|
|
|
87
151
|
refresh(): void;
|
|
88
152
|
}
|
|
89
153
|
|
|
154
|
+
const noSubscription = () => () => undefined;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The quota the client last saw, from a chat's `done` event or from
|
|
158
|
+
* `getQuota()`. One source, so a figure on screen is not stale the moment a
|
|
159
|
+
* chat finishes (AI-13).
|
|
160
|
+
*/
|
|
161
|
+
function useAiQuotaValue(client: AiClient): QuotaSnapshot | undefined {
|
|
162
|
+
return useExternalStore(
|
|
163
|
+
client.subscribe ?? noSubscription,
|
|
164
|
+
client.lastQuota ?? (() => undefined),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
90
168
|
export function useAiQuota(client: AiClient): UseAiQuotaResult {
|
|
91
|
-
const
|
|
169
|
+
const fromClient = useAiQuotaValue(client);
|
|
170
|
+
// A client of the app's own may not keep the last quota; then this is it.
|
|
171
|
+
const [fetched, setFetched] = useState<QuotaSnapshot | undefined>();
|
|
172
|
+
const quota = fromClient ?? fetched;
|
|
92
173
|
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
93
174
|
const alive = useRef(true);
|
|
94
175
|
// Only the latest refresh may settle the state; an older, slower one that
|
|
@@ -103,7 +184,7 @@ export function useAiQuota(client: AiClient): UseAiQuotaResult {
|
|
|
103
184
|
.getQuota()
|
|
104
185
|
.then(snapshot => {
|
|
105
186
|
if (!current()) return;
|
|
106
|
-
|
|
187
|
+
setFetched(snapshot);
|
|
107
188
|
setStatus('ready');
|
|
108
189
|
})
|
|
109
190
|
.catch(() => {
|
|
@@ -119,5 +200,14 @@ export function useAiQuota(client: AiClient): UseAiQuotaResult {
|
|
|
119
200
|
};
|
|
120
201
|
}, [refresh]);
|
|
121
202
|
|
|
122
|
-
|
|
203
|
+
// A quota that arrived through a chat is an answer too: nothing is loading.
|
|
204
|
+
return { quota, status: status === 'loading' && quota ? 'ready' : status, refresh };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The client's behavior state (config source and whether AI is enabled), live:
|
|
209
|
+
* re-renders when the kill switch flips or the config state moves (AI-09).
|
|
210
|
+
*/
|
|
211
|
+
export function useAiBehaviorState(client: AiClient): AiBehaviorState {
|
|
212
|
+
return useExternalStore(client.subscribe ?? noSubscription, client.getBehaviorState);
|
|
123
213
|
}
|
|
@@ -22,20 +22,42 @@ export interface SseRequest {
|
|
|
22
22
|
idleTimeoutMs?: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** What the response said besides its body. Absent for a failure with no response. */
|
|
26
|
+
export interface SseResponseInfo {
|
|
27
|
+
/** A response header, or null. `Retry-After` is the one this package reads. */
|
|
28
|
+
getHeader(name: string): string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
export interface SseHandlers {
|
|
26
32
|
/** One call per `data:` event payload (raw string, already de-framed). */
|
|
27
33
|
onData(payload: string): void;
|
|
28
34
|
/** Stream closed normally (transport level; `[DONE]` handling is the caller's). */
|
|
29
35
|
onEnd(): void;
|
|
30
36
|
/** Transport or HTTP error. `status` is 0 for network failures. */
|
|
31
|
-
onError(status: number, bodyText: string): void;
|
|
37
|
+
onError(status: number, bodyText: string, response?: SseResponseInfo): void;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface SseHandle {
|
|
35
41
|
cancel(): void;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* The transport port: how this package makes a streaming request.
|
|
46
|
+
*
|
|
47
|
+
* A published port rather than `typeof requestSse`, so the default reader's
|
|
48
|
+
* own signature is not part of the public API (AI-18): an app may inject its
|
|
49
|
+
* own transport, and this package may change how `requestSse` is written
|
|
50
|
+
* without that being a breaking change.
|
|
51
|
+
*/
|
|
52
|
+
export type StreamTransport = (request: SseRequest, handlers: SseHandlers) => SseHandle;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse complete `data:` events out of `text` starting at `fromIndex`.
|
|
56
|
+
*
|
|
57
|
+
* Both line endings are accepted. A proxy that normalises to CRLF — an API
|
|
58
|
+
* gateway or a corporate middlebox — used to make every event invisible until
|
|
59
|
+
* the stream closed, because only `\n\n` counted as a separator (AI-22).
|
|
60
|
+
*/
|
|
39
61
|
export function drainSseEvents(
|
|
40
62
|
text: string,
|
|
41
63
|
fromIndex: number,
|
|
@@ -43,15 +65,19 @@ export function drainSseEvents(
|
|
|
43
65
|
): number {
|
|
44
66
|
let cursor = fromIndex;
|
|
45
67
|
for (;;) {
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
const separator = /\r\n\r\n|\n\n|\r\r/.exec(text.slice(cursor));
|
|
69
|
+
if (!separator) {
|
|
70
|
+
return cursor; // incomplete event stays buffered
|
|
71
|
+
}
|
|
72
|
+
const rawEvent = text.slice(cursor, cursor + separator.index);
|
|
73
|
+
cursor += separator.index + separator[0].length;
|
|
50
74
|
const dataLines = rawEvent
|
|
51
|
-
.split(
|
|
75
|
+
.split(/\r\n|\n|\r/)
|
|
52
76
|
.filter(line => line.startsWith('data:'))
|
|
53
77
|
.map(line => line.slice(5).trimStart());
|
|
54
|
-
if (dataLines.length > 0)
|
|
78
|
+
if (dataLines.length > 0) {
|
|
79
|
+
emit(dataLines.join('\n'));
|
|
80
|
+
}
|
|
55
81
|
}
|
|
56
82
|
}
|
|
57
83
|
|
|
@@ -71,6 +97,18 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
|
|
|
71
97
|
}
|
|
72
98
|
};
|
|
73
99
|
|
|
100
|
+
/** The response's own headers, for `Retry-After` on a 429 (AI-11). */
|
|
101
|
+
const response: SseResponseInfo = {
|
|
102
|
+
getHeader(name) {
|
|
103
|
+
try {
|
|
104
|
+
return xhr.getResponseHeader(name);
|
|
105
|
+
} catch {
|
|
106
|
+
// Headers are unavailable before they arrive, and on some polyfills.
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
74
112
|
const finish = (fn: () => void) => {
|
|
75
113
|
disarm();
|
|
76
114
|
if (!finished && !cancelled) {
|
|
@@ -96,10 +134,19 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
|
|
|
96
134
|
if (ms && !finished && !cancelled) stallTimer = setTimeout(stalled, ms);
|
|
97
135
|
};
|
|
98
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Drains whatever has arrived. `onprogress` and `readyState === 3` both fire
|
|
139
|
+
* for the same chunk, and which of the two fires at all has been platform
|
|
140
|
+
* dependent, so both stay registered; the length check makes the second call
|
|
141
|
+
* for one chunk free rather than another full `responseText` drain (AI-V1).
|
|
142
|
+
*/
|
|
143
|
+
let drained = -1;
|
|
99
144
|
const pump = () => {
|
|
100
145
|
if (xhr.status !== 200) return; // error path handled on load/error
|
|
101
|
-
arm(request.idleTimeoutMs);
|
|
102
146
|
const text: string = xhr.responseText ?? '';
|
|
147
|
+
if (text.length === drained) return;
|
|
148
|
+
drained = text.length;
|
|
149
|
+
arm(request.idleTimeoutMs);
|
|
103
150
|
seen = drainSseEvents(text, seen, handlers.onData);
|
|
104
151
|
};
|
|
105
152
|
|
|
@@ -112,7 +159,7 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
|
|
|
112
159
|
|
|
113
160
|
xhr.onprogress = pump;
|
|
114
161
|
xhr.onreadystatechange = () => {
|
|
115
|
-
if (xhr.readyState === 2) arm(request.
|
|
162
|
+
if (xhr.readyState === 2) arm(request.connectTimeoutMs); // HEADERS_RECEIVED
|
|
116
163
|
if (xhr.readyState === 3) pump(); // LOADING: incremental text
|
|
117
164
|
};
|
|
118
165
|
xhr.onload = () => {
|
|
@@ -120,7 +167,7 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
|
|
|
120
167
|
pump();
|
|
121
168
|
finish(handlers.onEnd);
|
|
122
169
|
} else {
|
|
123
|
-
finish(() => handlers.onError(xhr.status, xhr.responseText ?? ''));
|
|
170
|
+
finish(() => handlers.onError(xhr.status, xhr.responseText ?? '', response));
|
|
124
171
|
}
|
|
125
172
|
};
|
|
126
173
|
xhr.onerror = () => finish(() => handlers.onError(0, 'network error'));
|