@chipmobilesdk/rn-ai 0.2.0 → 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 +158 -55
- package/README.md +159 -151
- package/package.json +39 -43
- package/skills/sdk-rn-ai/SKILL.md +56 -57
- package/src/config/behaviorConfig.ts +233 -200
- package/src/core/aiClient.ts +354 -281
- package/src/diagnostics/errors.ts +89 -75
- package/src/diagnostics/messageKeys.ts +63 -27
- package/src/index.ts +31 -35
- package/src/react/hooks.ts +141 -134
- package/src/transport/sseClient.ts +143 -143
- package/src/transport/timeouts.ts +53 -53
|
@@ -1,75 +1,89 @@
|
|
|
1
|
-
/** Typed client errors (contracts/rn-ai-public-api.md). */
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
| '
|
|
6
|
-
| '
|
|
7
|
-
| '
|
|
8
|
-
| '
|
|
9
|
-
| '
|
|
10
|
-
| '
|
|
11
|
-
| '
|
|
12
|
-
| '
|
|
13
|
-
| '
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
1
|
+
/** Typed client errors (contracts/rn-ai-public-api.md). */
|
|
2
|
+
import { SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
|
|
3
|
+
|
|
4
|
+
export type AiErrorCode =
|
|
5
|
+
| 'auth-invalid'
|
|
6
|
+
| 'not-entitled'
|
|
7
|
+
| 'user-blocked'
|
|
8
|
+
| 'app-unknown'
|
|
9
|
+
| 'rate-limited'
|
|
10
|
+
| 'upstream-busy'
|
|
11
|
+
| 'payload-too-large'
|
|
12
|
+
| 'modality-not-allowed'
|
|
13
|
+
| 'config-unavailable'
|
|
14
|
+
| 'upstream-error'
|
|
15
|
+
| 'internal';
|
|
16
|
+
|
|
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;
|
|
28
|
+
|
|
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
|
+
});
|
|
37
|
+
this.name = 'AiError';
|
|
38
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @deprecated Use `retryable`, the name every SDK package shares. */
|
|
42
|
+
get recoverable(): boolean {
|
|
43
|
+
return this.retryable;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isAiError(value: unknown): value is AiError {
|
|
48
|
+
return value instanceof AiError;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const HTTP_CODE_MAP: Record<string, AiErrorCode> = {
|
|
52
|
+
AUTH_INVALID: 'auth-invalid',
|
|
53
|
+
NOT_ENTITLED: 'not-entitled',
|
|
54
|
+
USER_BLOCKED: 'user-blocked',
|
|
55
|
+
APP_UNKNOWN: 'app-unknown',
|
|
56
|
+
PAYLOAD_TOO_LARGE: 'payload-too-large',
|
|
57
|
+
MODALITY_NOT_ALLOWED: 'modality-not-allowed',
|
|
58
|
+
RATE_LIMITED: 'rate-limited',
|
|
59
|
+
UPSTREAM_BUSY: 'upstream-busy',
|
|
60
|
+
UPSTREAM_ERROR: 'upstream-error',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Map a gateway error response (status + JSON body) to a typed AiError. */
|
|
64
|
+
export function errorFromHttp(status: number, bodyText: string): AiError {
|
|
65
|
+
let code: AiErrorCode = status >= 500 ? 'upstream-error' : 'internal';
|
|
66
|
+
let message = `gateway responded ${status}`;
|
|
67
|
+
let retryAfterSeconds: number | undefined;
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(bodyText) as {
|
|
70
|
+
error?: { code?: string; message?: string; retryAfterSeconds?: number };
|
|
71
|
+
};
|
|
72
|
+
if (parsed.error?.code && HTTP_CODE_MAP[parsed.error.code]) {
|
|
73
|
+
code = HTTP_CODE_MAP[parsed.error.code];
|
|
74
|
+
}
|
|
75
|
+
// The gateway's text can quote upstream detail; keep it readable but shaped.
|
|
76
|
+
if (parsed.error?.message) message = redactPatterns(parsed.error.message);
|
|
77
|
+
retryAfterSeconds = parsed.error?.retryAfterSeconds;
|
|
78
|
+
} catch {
|
|
79
|
+
if (status === 0) {
|
|
80
|
+
code = 'upstream-error';
|
|
81
|
+
message = 'network failure';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Status 0 is a dropped connection, which a retry may well fix.
|
|
85
|
+
return new AiError(code, message, {
|
|
86
|
+
retryAfterSeconds,
|
|
87
|
+
recoverable: status === 0 ? true : undefined,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -1,27 +1,63 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* User-facing copy keys (UX-002/003),
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
1
|
+
/**
|
|
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.
|
|
5
|
+
*/
|
|
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;
|
|
35
|
+
|
|
36
|
+
export type AiMessageKey = keyof typeof aiMessages;
|
|
37
|
+
|
|
38
|
+
export function aiMessage(key: AiMessageKey): string {
|
|
39
|
+
return aiMessages[key];
|
|
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
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} from './
|
|
27
|
-
|
|
28
|
-
export
|
|
29
|
-
export {
|
|
30
|
-
export
|
|
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 { useAiBehaviorState, 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 { aiMessage, aiMessageSet, aiMessages, messageKeyForError } from './diagnostics/messageKeys';
|
|
31
|
+
export type { AiMessageKey } from './diagnostics/messageKeys';
|
package/src/react/hooks.ts
CHANGED
|
@@ -1,134 +1,141 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const [
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
1
|
+
import { useExternalStore } from '@chipmobilesdk/rn-core/react';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import type {
|
|
4
|
+
AiBehaviorState,
|
|
5
|
+
AiClient,
|
|
6
|
+
ChatHandle,
|
|
7
|
+
ChatRequest,
|
|
8
|
+
QuotaSnapshot,
|
|
9
|
+
} from '../core/aiClient';
|
|
10
|
+
import type { AiError } from '../diagnostics/errors';
|
|
11
|
+
|
|
12
|
+
export type AiChatStatus = 'idle' | 'loading' | 'streaming' | 'done' | 'error' | 'rate-limited';
|
|
13
|
+
|
|
14
|
+
export interface UseAiChatResult {
|
|
15
|
+
send(request: ChatRequest): void;
|
|
16
|
+
cancel(): void;
|
|
17
|
+
status: AiChatStatus;
|
|
18
|
+
/** Accumulated streamed text (appends incrementally — PERF-002). */
|
|
19
|
+
text: string;
|
|
20
|
+
error?: AiError;
|
|
21
|
+
retryAt?: Date;
|
|
22
|
+
quota?: QuotaSnapshot;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useAiChat(client: AiClient): UseAiChatResult {
|
|
26
|
+
const [status, setStatus] = useState<AiChatStatus>('idle');
|
|
27
|
+
const [text, setText] = useState('');
|
|
28
|
+
const [error, setError] = useState<AiError | undefined>();
|
|
29
|
+
const [retryAt, setRetryAt] = useState<Date | undefined>();
|
|
30
|
+
const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
|
|
31
|
+
const handleRef = useRef<ChatHandle | null>(null);
|
|
32
|
+
// Bumped by every send, cancel and unmount. A callback from an older request
|
|
33
|
+
// is dropped, so it can never overwrite the state of the one on screen —
|
|
34
|
+
// defence in depth behind the client's own cancel guard.
|
|
35
|
+
const requestRef = useRef(0);
|
|
36
|
+
|
|
37
|
+
const send = useCallback(
|
|
38
|
+
(request: ChatRequest) => {
|
|
39
|
+
handleRef.current?.cancel();
|
|
40
|
+
const id = ++requestRef.current;
|
|
41
|
+
const current = () => requestRef.current === id;
|
|
42
|
+
setText('');
|
|
43
|
+
setError(undefined);
|
|
44
|
+
setRetryAt(undefined);
|
|
45
|
+
setStatus('loading');
|
|
46
|
+
handleRef.current = client.chat(request, {
|
|
47
|
+
onDelta: delta => {
|
|
48
|
+
if (!current()) return;
|
|
49
|
+
setStatus('streaming');
|
|
50
|
+
setText(previous => previous + delta);
|
|
51
|
+
},
|
|
52
|
+
onDone: result => {
|
|
53
|
+
if (!current()) return;
|
|
54
|
+
if (result.quota) setQuota(result.quota);
|
|
55
|
+
setStatus('done');
|
|
56
|
+
},
|
|
57
|
+
onError: aiError => {
|
|
58
|
+
if (!current()) return;
|
|
59
|
+
setError(aiError);
|
|
60
|
+
if (aiError.code === 'rate-limited') {
|
|
61
|
+
setStatus('rate-limited');
|
|
62
|
+
if (aiError.retryAfterSeconds !== undefined) {
|
|
63
|
+
setRetryAt(new Date(Date.now() + aiError.retryAfterSeconds * 1000));
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
setStatus('error');
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
[client],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const cancel = useCallback(() => {
|
|
75
|
+
requestRef.current += 1;
|
|
76
|
+
handleRef.current?.cancel();
|
|
77
|
+
setStatus('idle');
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
useEffect(
|
|
81
|
+
() => () => {
|
|
82
|
+
requestRef.current += 1;
|
|
83
|
+
handleRef.current?.cancel();
|
|
84
|
+
},
|
|
85
|
+
[],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return { send, cancel, status, text, error, retryAt, quota };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface UseAiQuotaResult {
|
|
92
|
+
quota?: QuotaSnapshot;
|
|
93
|
+
status: 'loading' | 'ready' | 'error';
|
|
94
|
+
refresh(): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function useAiQuota(client: AiClient): UseAiQuotaResult {
|
|
98
|
+
const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
|
|
99
|
+
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
100
|
+
const alive = useRef(true);
|
|
101
|
+
// Only the latest refresh may settle the state; an older, slower one that
|
|
102
|
+
// lands afterwards is stale.
|
|
103
|
+
const latest = useRef(0);
|
|
104
|
+
|
|
105
|
+
const refresh = useCallback(() => {
|
|
106
|
+
const id = ++latest.current;
|
|
107
|
+
const current = () => alive.current && latest.current === id;
|
|
108
|
+
setStatus('loading');
|
|
109
|
+
client
|
|
110
|
+
.getQuota()
|
|
111
|
+
.then(snapshot => {
|
|
112
|
+
if (!current()) return;
|
|
113
|
+
setQuota(snapshot);
|
|
114
|
+
setStatus('ready');
|
|
115
|
+
})
|
|
116
|
+
.catch(() => {
|
|
117
|
+
if (current()) setStatus('error');
|
|
118
|
+
});
|
|
119
|
+
}, [client]);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
alive.current = true;
|
|
123
|
+
refresh();
|
|
124
|
+
return () => {
|
|
125
|
+
alive.current = false;
|
|
126
|
+
};
|
|
127
|
+
}, [refresh]);
|
|
128
|
+
|
|
129
|
+
return { quota, status, refresh };
|
|
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;
|