@tanstack/ai-client 0.31.0 → 0.32.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/README.md +14 -13
- package/dist/esm/byok/passkey.js +16 -0
- package/dist/esm/byok/passkey.js.map +1 -1
- package/dist/esm/chat-client.d.ts +27 -0
- package/dist/esm/chat-client.js +100 -4
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +25 -4
- package/dist/esm/connection-adapters.js +50 -28
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/types.d.ts +9 -0
- package/dist/esm/types.js.map +1 -1
- package/package.json +3 -3
- package/src/byok/passkey.ts +38 -0
- package/src/chat-client.ts +130 -3
- package/src/connection-adapters.ts +115 -33
- package/src/types.ts +9 -0
package/README.md
CHANGED
|
@@ -202,19 +202,20 @@ Learn more in the
|
|
|
202
202
|
|
|
203
203
|
Official adapters include:
|
|
204
204
|
|
|
205
|
-
| Package | Use it for
|
|
206
|
-
| ------------------------------------------------------------------------------------ |
|
|
207
|
-
| [`@tanstack/ai-openrouter`](https://tanstack.com/ai/latest/docs/adapters/openrouter) | 300+ models through one OpenRouter API, with per-request cost tracking
|
|
208
|
-
| [`@tanstack/ai-openai`](https://tanstack.com/ai/latest/docs/adapters/openai) | OpenAI chat, image, video, speech, transcription, realtime, and provider tools
|
|
209
|
-
| [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, structured outputs, and Vertex Claude
|
|
210
|
-
| [`@tanstack/ai-gemini`](https://tanstack.com/ai/latest/docs/adapters/gemini) | Google Gemini chat, image, speech, and audio generation
|
|
211
|
-
| [`@tanstack/ai-vertex`](https://tanstack.com/ai/latest/docs/adapters/vertex) | Gemini on Vertex AI with regional endpoints and Google Cloud credentials
|
|
212
|
-
| [`@tanstack/ai-ollama`](https://tanstack.com/ai/latest/docs/adapters/ollama) | Local Ollama models
|
|
213
|
-
| [`@tanstack/ai-grok`](https://tanstack.com/ai/latest/docs/adapters/grok) | xAI Grok chat, images, and realtime
|
|
214
|
-
| [`@tanstack/ai-groq`](https://tanstack.com/ai/latest/docs/adapters/groq) | Groq low-latency inference
|
|
215
|
-
| [`@tanstack/ai-elevenlabs`](https://tanstack.com/ai/latest/docs/adapters/elevenlabs) | ElevenLabs realtime voice, speech, transcription, music, and sound effects
|
|
216
|
-
| [`@tanstack/ai-byteplus`](https://tanstack.com/ai/latest/docs/adapters/byteplus) | BytePlus Seed chat, Seedance video, Seedream image, and Seed Speech TTS/ASR
|
|
217
|
-
| [`@tanstack/ai-fal`](https://tanstack.com/ai/latest/docs/adapters/fal) | fal.ai image, video, audio, speech, and transcription models
|
|
205
|
+
| Package | Use it for |
|
|
206
|
+
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
|
|
207
|
+
| [`@tanstack/ai-openrouter`](https://tanstack.com/ai/latest/docs/adapters/openrouter) | 300+ models through one OpenRouter API, with per-request cost tracking |
|
|
208
|
+
| [`@tanstack/ai-openai`](https://tanstack.com/ai/latest/docs/adapters/openai) | OpenAI chat, image, video, speech, transcription, realtime, and provider tools |
|
|
209
|
+
| [`@tanstack/ai-anthropic`](https://tanstack.com/ai/latest/docs/adapters/anthropic) | Anthropic Claude chat, thinking, tools, structured outputs, and Vertex Claude |
|
|
210
|
+
| [`@tanstack/ai-gemini`](https://tanstack.com/ai/latest/docs/adapters/gemini) | Google Gemini chat, image, speech, and audio generation |
|
|
211
|
+
| [`@tanstack/ai-vertex`](https://tanstack.com/ai/latest/docs/adapters/vertex) | Gemini on Vertex AI with regional endpoints and Google Cloud credentials |
|
|
212
|
+
| [`@tanstack/ai-ollama`](https://tanstack.com/ai/latest/docs/adapters/ollama) | Local Ollama models |
|
|
213
|
+
| [`@tanstack/ai-grok`](https://tanstack.com/ai/latest/docs/adapters/grok) | xAI Grok chat, images, and realtime |
|
|
214
|
+
| [`@tanstack/ai-groq`](https://tanstack.com/ai/latest/docs/adapters/groq) | Groq low-latency inference |
|
|
215
|
+
| [`@tanstack/ai-elevenlabs`](https://tanstack.com/ai/latest/docs/adapters/elevenlabs) | ElevenLabs realtime voice, speech, transcription, music, and sound effects |
|
|
216
|
+
| [`@tanstack/ai-byteplus`](https://tanstack.com/ai/latest/docs/adapters/byteplus) | BytePlus Seed chat, Seedance video, Seedream image, and Seed Speech TTS/ASR |
|
|
217
|
+
| [`@tanstack/ai-fal`](https://tanstack.com/ai/latest/docs/adapters/fal) | fal.ai image, video, audio, speech, and transcription models |
|
|
218
|
+
| [`@tanstack/ai-cloudflare`](https://tanstack.com/ai/latest/docs/adapters/cloudflare) | Cloudflare Workers AI chat, embeddings, image, speech, transcription, and AI Gateway |
|
|
218
219
|
|
|
219
220
|
The adapter system is tree-shakeable by activity. Import `openaiText` for chat,
|
|
220
221
|
`openaiImage` for images, `falVideo` for video, `geminiSpeech` for TTS, and so
|
package/dist/esm/byok/passkey.js
CHANGED
|
@@ -43,6 +43,10 @@ function isPasskeyStorageSupported() {
|
|
|
43
43
|
}
|
|
44
44
|
/** Derive a non-extractable AES-256-GCM key from a 32-byte PRF output. */
|
|
45
45
|
async function deriveAesKey(prfOutput) {
|
|
46
|
+
if (Array.isArray(prfOutput)) {
|
|
47
|
+
if (prfOutput.length !== 32 || !Array.from(prfOutput).every((byte) => typeof byte === "number" && Number.isInteger(byte) && byte >= 0 && byte <= 255)) throw new Error("Invalid passkey PRF byte array");
|
|
48
|
+
prfOutput = new Uint8Array(prfOutput);
|
|
49
|
+
}
|
|
46
50
|
const base = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveKey"]);
|
|
47
51
|
return crypto.subtle.deriveKey({
|
|
48
52
|
name: "HKDF",
|
|
@@ -111,6 +115,17 @@ function requirePublicKeyCredential(credential, action) {
|
|
|
111
115
|
if (!(credential instanceof PublicKeyCredential)) throw new Error(`Unexpected credential type during ${action}`);
|
|
112
116
|
return credential;
|
|
113
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Some browsers (Dia, Safari) silently suppress `navigator.credentials.get()`
|
|
120
|
+
* — no prompt, never resolves — when it runs without transient user activation.
|
|
121
|
+
* The activation from a click expires (~5s) and is lost across enough async
|
|
122
|
+
* work, so an unlock buried deep in a send pipeline just hangs. Fail fast with
|
|
123
|
+
* a clear, catchable error so the app can re-run it from a fresh gesture.
|
|
124
|
+
*/
|
|
125
|
+
function requireUserActivation(action) {
|
|
126
|
+
const activation = globalThis.navigator?.userActivation;
|
|
127
|
+
if (activation && activation.isActive === false) throw new Error(`Passkey ${action} needs a fresh user action (e.g. a click). Run it directly from the event handler, before awaiting other work.`);
|
|
128
|
+
}
|
|
114
129
|
async function registerPasskey(rpName, userName, rpId) {
|
|
115
130
|
const salt = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
|
|
116
131
|
const credential = requirePublicKeyCredential(await navigator.credentials.create({ publicKey: {
|
|
@@ -178,6 +193,7 @@ function passkeyStorage(options = {}) {
|
|
|
178
193
|
};
|
|
179
194
|
const existing = await idbGet(dbName);
|
|
180
195
|
if (existing) {
|
|
196
|
+
requireUserActivation("unlock");
|
|
181
197
|
cachedKey = await deriveAesKey(await evaluatePrf(existing.credentialId, existing.salt));
|
|
182
198
|
cachedMeta = {
|
|
183
199
|
credentialId: existing.credentialId,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"passkey.js","names":[],"sources":["../../../src/byok/passkey.ts"],"sourcesContent":["import { isProviderId } from '@tanstack/ai/byok'\nimport { memoryStorage } from './storage'\nimport type { KeyPreview, Keyring, KeyringStorage } from './storage'\n\n/**\n * Passkey-encrypted keyring storage (WebAuthn PRF → HKDF → AES-256-GCM).\n *\n * The keyring is encrypted at rest in IndexedDB with an AES-256-GCM key derived\n * from a passkey's PRF output, unwrapped on demand with a biometric/PIN tap.\n * Decryption happens entirely client-side with the user present — no server,\n * no custodian.\n *\n * Honest scope: this protects against at-rest theft (stolen device,\n * storage-dumping extension, backups). It does NOT defeat live in-page XSS —\n * an attacker running JS in the origin after the user unlocks can read the\n * decrypted keys from memory.\n */\n\nconst STORE_NAME = 'keyring'\nconst RECORD_ID = 'default'\nconst HKDF_INFO = 'byok:keyring:v1'\nconst DEFAULT_DB = 'byok'\n\ninterface StoredRecord {\n id: string\n /** The passkey's raw credential id, replayed in the unlock ceremony. */\n credentialId: ArrayBuffer\n /** Fixed per-install PRF evaluation input (not secret). */\n salt: ArrayBuffer\n /** AES-GCM initialization vector for this ciphertext. */\n iv: ArrayBuffer\n /** Encrypted keyring JSON. */\n ciphertext: ArrayBuffer\n /**\n * Unencrypted presence metadata (`provider → last 4`). Non-sensitive, so it\n * can be read via {@link KeyringStorage.peek} without an unlock ceremony to\n * show saved keys as \"locked\" after a refresh.\n */\n preview: KeyPreview\n}\n\nfunction sanitizeKeyring(value: unknown): Keyring {\n if (typeof value !== 'object' || value === null) return {}\n const keys: Keyring = {}\n for (const [provider, key] of Object.entries(value)) {\n if (isProviderId(provider) && typeof key === 'string' && key.length > 0) {\n keys[provider] = key\n }\n }\n return keys\n}\n\n/** Build the non-sensitive `provider → last 4` preview from a keyring. */\nfunction previewOf(keys: Keyring): KeyPreview {\n const preview: KeyPreview = {}\n for (const [provider, key] of Object.entries(keys)) {\n if (!key || !isProviderId(provider)) continue\n // Keys of length ≤ 4 would make last-4 the whole secret — store presence only.\n preview[provider] = key.length > 4 ? key.slice(-4) : ''\n }\n return preview\n}\n\n/**\n * Whether the current environment exposes WebAuthn. Actual PRF support can\n * only be confirmed during registration; `passkeyStorage` throws if the\n * chosen authenticator does not support PRF.\n */\nexport function isPasskeyStorageSupported(): boolean {\n return (\n typeof globalThis !== 'undefined' &&\n typeof globalThis.PublicKeyCredential !== 'undefined' &&\n typeof globalThis.navigator !== 'undefined' &&\n typeof globalThis.navigator.credentials.create === 'function'\n )\n}\n\n// ---------------------------------------------------------------------------\n// Crypto (exported for testing; the WebAuthn ceremony below feeds `deriveAesKey`)\n// ---------------------------------------------------------------------------\n\n/** Derive a non-extractable AES-256-GCM key from a 32-byte PRF output. */\nexport async function deriveAesKey(\n prfOutput: BufferSource,\n): Promise<CryptoKey> {\n const base = await crypto.subtle.importKey('raw', prfOutput, 'HKDF', false, [\n 'deriveKey',\n ])\n return crypto.subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new Uint8Array(0),\n info: new TextEncoder().encode(HKDF_INFO),\n },\n base,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\nexport async function encryptKeyring(\n key: CryptoKey,\n keys: Keyring,\n): Promise<{ iv: ArrayBuffer; ciphertext: ArrayBuffer }> {\n const iv = crypto.getRandomValues(new Uint8Array(12))\n const plaintext = new TextEncoder().encode(JSON.stringify(keys))\n const ciphertext = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv },\n key,\n plaintext,\n )\n return { iv: iv.buffer, ciphertext }\n}\n\nexport async function decryptKeyring(\n key: CryptoKey,\n iv: BufferSource,\n ciphertext: BufferSource,\n): Promise<Keyring> {\n const plaintext = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv },\n key,\n ciphertext,\n )\n const parsed: unknown = JSON.parse(new TextDecoder().decode(plaintext))\n return sanitizeKeyring(parsed)\n}\n\n// ---------------------------------------------------------------------------\n// IndexedDB\n// ---------------------------------------------------------------------------\n\nfunction openDb(dbName: string): Promise<IDBDatabase> {\n return new Promise((resolve, reject) => {\n const request = indexedDB.open(dbName, 1)\n request.onupgradeneeded = () => {\n request.result.createObjectStore(STORE_NAME, { keyPath: 'id' })\n }\n request.onsuccess = () => resolve(request.result)\n request.onerror = () => reject(request.error)\n })\n}\n\nfunction idbGet(dbName: string): Promise<StoredRecord | null> {\n return openDb(dbName).then(\n (db) =>\n new Promise((resolve, reject) => {\n const request = db\n .transaction(STORE_NAME, 'readonly')\n .objectStore(STORE_NAME)\n .get(RECORD_ID)\n request.onsuccess = () => resolve(request.result ?? null)\n request.onerror = () => reject(request.error)\n }),\n )\n}\n\nfunction idbPut(dbName: string, record: StoredRecord): Promise<void> {\n return openDb(dbName).then(\n (db) =>\n new Promise((resolve, reject) => {\n const tx = db.transaction(STORE_NAME, 'readwrite')\n tx.objectStore(STORE_NAME).put(record)\n tx.oncomplete = () => resolve()\n tx.onerror = () => reject(tx.error)\n }),\n )\n}\n\nfunction idbClear(dbName: string): Promise<void> {\n return openDb(dbName).then(\n (db) =>\n new Promise((resolve, reject) => {\n const tx = db.transaction(STORE_NAME, 'readwrite')\n tx.objectStore(STORE_NAME).delete(RECORD_ID)\n tx.oncomplete = () => resolve()\n tx.onerror = () => reject(tx.error)\n }),\n )\n}\n\n// ---------------------------------------------------------------------------\n// WebAuthn ceremonies\n// ---------------------------------------------------------------------------\n\nfunction requirePublicKeyCredential(\n credential: Credential | null,\n action: string,\n): PublicKeyCredential {\n if (!credential) throw new Error(`Passkey ${action} was cancelled`)\n if (!(credential instanceof PublicKeyCredential)) {\n throw new Error(`Unexpected credential type during ${action}`)\n }\n return credential\n}\n\nasync function registerPasskey(\n rpName: string,\n userName: string,\n rpId?: string,\n): Promise<{\n credentialId: ArrayBuffer\n salt: Uint8Array<ArrayBuffer>\n prf?: BufferSource\n}> {\n const salt = crypto.getRandomValues(new Uint8Array(32))\n const credential = requirePublicKeyCredential(\n await navigator.credentials.create({\n publicKey: {\n challenge: crypto.getRandomValues(new Uint8Array(32)),\n // Omit `id` to let the browser bind the passkey to the current origin's\n // effective domain; set it to scope across subdomains of a self-host.\n rp: rpId ? { name: rpName, id: rpId } : { name: rpName },\n user: {\n id: crypto.getRandomValues(new Uint8Array(16)),\n name: userName,\n displayName: userName,\n },\n pubKeyCredParams: [\n { type: 'public-key', alg: -7 },\n { type: 'public-key', alg: -257 },\n ],\n authenticatorSelection: {\n residentKey: 'required',\n userVerification: 'required',\n },\n extensions: { prf: { eval: { first: salt } } },\n },\n }),\n 'registration',\n )\n\n const prf = credential.getClientExtensionResults().prf\n if (!prf?.enabled) {\n throw new Error(\n 'This authenticator does not support the WebAuthn PRF extension',\n )\n }\n return { credentialId: credential.rawId, salt, prf: prf.results?.first }\n}\n\nasync function evaluatePrf(\n credentialId: BufferSource,\n salt: BufferSource,\n): Promise<BufferSource> {\n const credential = requirePublicKeyCredential(\n await navigator.credentials.get({\n publicKey: {\n challenge: crypto.getRandomValues(new Uint8Array(32)),\n allowCredentials: [{ type: 'public-key', id: credentialId }],\n userVerification: 'required',\n extensions: { prf: { eval: { first: salt } } },\n },\n }),\n 'unlock',\n )\n const result = credential.getClientExtensionResults().prf?.results?.first\n if (!result) {\n throw new Error('Authenticator did not return a PRF result')\n }\n return result\n}\n\n// ---------------------------------------------------------------------------\n// Storage strategy\n// ---------------------------------------------------------------------------\n\nexport interface PasskeyStorageOptions {\n /** Relying-party name shown in the passkey prompt. */\n rpName?: string\n /** Username label attached to the created passkey. */\n userName?: string\n /**\n * WebAuthn Relying Party ID. Omit to bind the passkey to the current origin's\n * effective domain (the default — no central/hardcoded domain). Set it to a\n * registrable parent domain to share the credential across subdomains of your\n * own deployment. The encrypted keyring is never portable across unrelated\n * domains.\n */\n rpId?: string\n /** IndexedDB database name. Defaults to `byok`. */\n dbName?: string\n}\n\n/**\n * Passkey-encrypted persistence. `ByokClient` treats this as `unlockable`, so\n * nothing is decrypted until the user calls `unlock()` (or saves a key, which\n * registers a passkey on first use). The derived key is cached in memory for\n * the session so repeated saves don't re-prompt.\n */\nexport function passkeyStorage(\n options: PasskeyStorageOptions = {},\n): KeyringStorage {\n const rpName = options.rpName ?? 'BYOK'\n const userName = options.userName ?? 'byok-keyring'\n const { rpId } = options\n const dbName = options.dbName ?? DEFAULT_DB\n\n let cachedKey: CryptoKey | null = null\n let cachedMeta: {\n credentialId: ArrayBuffer\n salt: Uint8Array<ArrayBuffer>\n } | null = null\n\n // Obtain the AES key, running exactly one WebAuthn ceremony if it isn't\n // already cached for this session (unlock if a passkey exists, else register).\n async function ensureKey(): Promise<{\n key: CryptoKey\n credentialId: ArrayBuffer\n salt: Uint8Array<ArrayBuffer>\n }> {\n if (cachedKey && cachedMeta) {\n return { key: cachedKey, ...cachedMeta }\n }\n const existing = await idbGet(dbName)\n if (existing) {\n const prf = await evaluatePrf(existing.credentialId, existing.salt)\n cachedKey = await deriveAesKey(prf)\n cachedMeta = {\n credentialId: existing.credentialId,\n salt: new Uint8Array(existing.salt),\n }\n } else {\n const reg = await registerPasskey(rpName, userName, rpId)\n const prf = reg.prf ?? (await evaluatePrf(reg.credentialId, reg.salt))\n cachedKey = await deriveAesKey(prf)\n cachedMeta = { credentialId: reg.credentialId, salt: reg.salt }\n }\n return { key: cachedKey, ...cachedMeta }\n }\n\n return {\n id: 'passkey',\n label: 'Passkey-encrypted (this device)',\n persistent: true,\n unlockable: true,\n warning:\n 'Keys are encrypted with your passkey and unlocked with biometrics. ' +\n 'This protects saved keys if your device is stolen, but not against code ' +\n 'running on this page after you unlock.',\n peek: async () => {\n // Unencrypted last-4 sidecar — no key material, no unlock ceremony.\n const existing = await idbGet(dbName)\n return existing?.preview ?? {}\n },\n load: async () => {\n const existing = await idbGet(dbName)\n if (!existing) return {}\n const { key } = await ensureKey()\n return decryptKeyring(key, existing.iv, existing.ciphertext)\n },\n save: async (keys) => {\n const existing = await idbGet(dbName)\n const hasKeys = Object.values(keys).some(Boolean)\n // First save with an empty keyring is a no-op — avoids a passkey ceremony\n // when another storage tier writes an empty ring.\n if (!hasKeys && !existing) return\n\n const { key, credentialId, salt } = await ensureKey()\n const { iv, ciphertext } = await encryptKeyring(key, keys)\n await idbPut(dbName, {\n id: RECORD_ID,\n credentialId,\n salt: salt.buffer,\n iv,\n ciphertext,\n preview: previewOf(keys),\n })\n },\n clear: async () => {\n cachedKey = null\n cachedMeta = null\n await idbClear(dbName)\n },\n }\n}\n\n/**\n * Passkey-encrypted storage when WebAuthn is available in a secure context.\n * Otherwise session memory, with a warning — this is not an automatic PRF\n * fallback. First save still throws if the authenticator lacks PRF.\n */\nexport function defaultByokStorage(\n options?: PasskeyStorageOptions,\n): KeyringStorage {\n const secure =\n typeof globalThis.isSecureContext !== 'boolean' ||\n globalThis.isSecureContext\n if (!isPasskeyStorageSupported() || !secure) {\n return {\n ...memoryStorage(),\n warning:\n 'Passkeys are unavailable in this context. Keys stay in memory for this tab only.',\n }\n }\n return passkeyStorage(options)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"passkey.js","names":[],"sources":["../../../src/byok/passkey.ts"],"sourcesContent":["import { isProviderId } from '@tanstack/ai/byok'\nimport { memoryStorage } from './storage'\nimport type { KeyPreview, Keyring, KeyringStorage } from './storage'\n\n/**\n * Passkey-encrypted keyring storage (WebAuthn PRF → HKDF → AES-256-GCM).\n *\n * The keyring is encrypted at rest in IndexedDB with an AES-256-GCM key derived\n * from a passkey's PRF output, unwrapped on demand with a biometric/PIN tap.\n * Decryption happens entirely client-side with the user present — no server,\n * no custodian.\n *\n * Honest scope: this protects against at-rest theft (stolen device,\n * storage-dumping extension, backups). It does NOT defeat live in-page XSS —\n * an attacker running JS in the origin after the user unlocks can read the\n * decrypted keys from memory.\n */\n\nconst STORE_NAME = 'keyring'\nconst RECORD_ID = 'default'\nconst HKDF_INFO = 'byok:keyring:v1'\nconst DEFAULT_DB = 'byok'\n\ninterface StoredRecord {\n id: string\n /** The passkey's raw credential id, replayed in the unlock ceremony. */\n credentialId: ArrayBuffer\n /** Fixed per-install PRF evaluation input (not secret). */\n salt: ArrayBuffer\n /** AES-GCM initialization vector for this ciphertext. */\n iv: ArrayBuffer\n /** Encrypted keyring JSON. */\n ciphertext: ArrayBuffer\n /**\n * Unencrypted presence metadata (`provider → last 4`). Non-sensitive, so it\n * can be read via {@link KeyringStorage.peek} without an unlock ceremony to\n * show saved keys as \"locked\" after a refresh.\n */\n preview: KeyPreview\n}\n\nfunction sanitizeKeyring(value: unknown): Keyring {\n if (typeof value !== 'object' || value === null) return {}\n const keys: Keyring = {}\n for (const [provider, key] of Object.entries(value)) {\n if (isProviderId(provider) && typeof key === 'string' && key.length > 0) {\n keys[provider] = key\n }\n }\n return keys\n}\n\n/** Build the non-sensitive `provider → last 4` preview from a keyring. */\nfunction previewOf(keys: Keyring): KeyPreview {\n const preview: KeyPreview = {}\n for (const [provider, key] of Object.entries(keys)) {\n if (!key || !isProviderId(provider)) continue\n // Keys of length ≤ 4 would make last-4 the whole secret — store presence only.\n preview[provider] = key.length > 4 ? key.slice(-4) : ''\n }\n return preview\n}\n\n/**\n * Whether the current environment exposes WebAuthn. Actual PRF support can\n * only be confirmed during registration; `passkeyStorage` throws if the\n * chosen authenticator does not support PRF.\n */\nexport function isPasskeyStorageSupported(): boolean {\n return (\n typeof globalThis !== 'undefined' &&\n typeof globalThis.PublicKeyCredential !== 'undefined' &&\n typeof globalThis.navigator !== 'undefined' &&\n typeof globalThis.navigator.credentials.create === 'function'\n )\n}\n\n// ---------------------------------------------------------------------------\n// Crypto (exported for testing; the WebAuthn ceremony below feeds `deriveAesKey`)\n// ---------------------------------------------------------------------------\n\n/** Derive a non-extractable AES-256-GCM key from a 32-byte PRF output. */\nexport async function deriveAesKey(\n prfOutput: BufferSource,\n): Promise<CryptoKey> {\n // Some passkey providers return a plain byte array instead of BufferSource.\n if (Array.isArray(prfOutput)) {\n if (\n prfOutput.length !== 32 ||\n !Array.from(prfOutput).every(\n (byte: unknown) =>\n typeof byte === 'number' &&\n Number.isInteger(byte) &&\n byte >= 0 &&\n byte <= 255,\n )\n ) {\n throw new Error('Invalid passkey PRF byte array')\n }\n prfOutput = new Uint8Array(prfOutput)\n }\n const base = await crypto.subtle.importKey('raw', prfOutput, 'HKDF', false, [\n 'deriveKey',\n ])\n return crypto.subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new Uint8Array(0),\n info: new TextEncoder().encode(HKDF_INFO),\n },\n base,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n\nexport async function encryptKeyring(\n key: CryptoKey,\n keys: Keyring,\n): Promise<{ iv: ArrayBuffer; ciphertext: ArrayBuffer }> {\n const iv = crypto.getRandomValues(new Uint8Array(12))\n const plaintext = new TextEncoder().encode(JSON.stringify(keys))\n const ciphertext = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv },\n key,\n plaintext,\n )\n return { iv: iv.buffer, ciphertext }\n}\n\nexport async function decryptKeyring(\n key: CryptoKey,\n iv: BufferSource,\n ciphertext: BufferSource,\n): Promise<Keyring> {\n const plaintext = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv },\n key,\n ciphertext,\n )\n const parsed: unknown = JSON.parse(new TextDecoder().decode(plaintext))\n return sanitizeKeyring(parsed)\n}\n\n// ---------------------------------------------------------------------------\n// IndexedDB\n// ---------------------------------------------------------------------------\n\nfunction openDb(dbName: string): Promise<IDBDatabase> {\n return new Promise((resolve, reject) => {\n const request = indexedDB.open(dbName, 1)\n request.onupgradeneeded = () => {\n request.result.createObjectStore(STORE_NAME, { keyPath: 'id' })\n }\n request.onsuccess = () => resolve(request.result)\n request.onerror = () => reject(request.error)\n })\n}\n\nfunction idbGet(dbName: string): Promise<StoredRecord | null> {\n return openDb(dbName).then(\n (db) =>\n new Promise((resolve, reject) => {\n const request = db\n .transaction(STORE_NAME, 'readonly')\n .objectStore(STORE_NAME)\n .get(RECORD_ID)\n request.onsuccess = () => resolve(request.result ?? null)\n request.onerror = () => reject(request.error)\n }),\n )\n}\n\nfunction idbPut(dbName: string, record: StoredRecord): Promise<void> {\n return openDb(dbName).then(\n (db) =>\n new Promise((resolve, reject) => {\n const tx = db.transaction(STORE_NAME, 'readwrite')\n tx.objectStore(STORE_NAME).put(record)\n tx.oncomplete = () => resolve()\n tx.onerror = () => reject(tx.error)\n }),\n )\n}\n\nfunction idbClear(dbName: string): Promise<void> {\n return openDb(dbName).then(\n (db) =>\n new Promise((resolve, reject) => {\n const tx = db.transaction(STORE_NAME, 'readwrite')\n tx.objectStore(STORE_NAME).delete(RECORD_ID)\n tx.oncomplete = () => resolve()\n tx.onerror = () => reject(tx.error)\n }),\n )\n}\n\n// ---------------------------------------------------------------------------\n// WebAuthn ceremonies\n// ---------------------------------------------------------------------------\n\nfunction requirePublicKeyCredential(\n credential: Credential | null,\n action: string,\n): PublicKeyCredential {\n if (!credential) throw new Error(`Passkey ${action} was cancelled`)\n if (!(credential instanceof PublicKeyCredential)) {\n throw new Error(`Unexpected credential type during ${action}`)\n }\n return credential\n}\n\n/**\n * Some browsers (Dia, Safari) silently suppress `navigator.credentials.get()`\n * — no prompt, never resolves — when it runs without transient user activation.\n * The activation from a click expires (~5s) and is lost across enough async\n * work, so an unlock buried deep in a send pipeline just hangs. Fail fast with\n * a clear, catchable error so the app can re-run it from a fresh gesture.\n */\nfunction requireUserActivation(action: string): void {\n // ponytail: only enforce where the API exists; absent means \"can't tell\", so\n // fall through rather than block a ceremony that would have worked.\n const activation = globalThis.navigator?.userActivation\n if (activation && activation.isActive === false) {\n throw new Error(\n `Passkey ${action} needs a fresh user action (e.g. a click). Run it ` +\n 'directly from the event handler, before awaiting other work.',\n )\n }\n}\n\nasync function registerPasskey(\n rpName: string,\n userName: string,\n rpId?: string,\n): Promise<{\n credentialId: ArrayBuffer\n salt: Uint8Array<ArrayBuffer>\n prf?: BufferSource\n}> {\n const salt = crypto.getRandomValues(new Uint8Array(32))\n const credential = requirePublicKeyCredential(\n await navigator.credentials.create({\n publicKey: {\n challenge: crypto.getRandomValues(new Uint8Array(32)),\n // Omit `id` to let the browser bind the passkey to the current origin's\n // effective domain; set it to scope across subdomains of a self-host.\n rp: rpId ? { name: rpName, id: rpId } : { name: rpName },\n user: {\n id: crypto.getRandomValues(new Uint8Array(16)),\n name: userName,\n displayName: userName,\n },\n pubKeyCredParams: [\n { type: 'public-key', alg: -7 },\n { type: 'public-key', alg: -257 },\n ],\n authenticatorSelection: {\n residentKey: 'required',\n userVerification: 'required',\n },\n extensions: { prf: { eval: { first: salt } } },\n },\n }),\n 'registration',\n )\n\n const prf = credential.getClientExtensionResults().prf\n if (!prf?.enabled) {\n throw new Error(\n 'This authenticator does not support the WebAuthn PRF extension',\n )\n }\n return { credentialId: credential.rawId, salt, prf: prf.results?.first }\n}\n\nasync function evaluatePrf(\n credentialId: BufferSource,\n salt: BufferSource,\n): Promise<BufferSource> {\n const credential = requirePublicKeyCredential(\n await navigator.credentials.get({\n publicKey: {\n challenge: crypto.getRandomValues(new Uint8Array(32)),\n allowCredentials: [{ type: 'public-key', id: credentialId }],\n userVerification: 'required',\n extensions: { prf: { eval: { first: salt } } },\n },\n }),\n 'unlock',\n )\n const result = credential.getClientExtensionResults().prf?.results?.first\n if (!result) {\n throw new Error('Authenticator did not return a PRF result')\n }\n return result\n}\n\n// ---------------------------------------------------------------------------\n// Storage strategy\n// ---------------------------------------------------------------------------\n\nexport interface PasskeyStorageOptions {\n /** Relying-party name shown in the passkey prompt. */\n rpName?: string\n /** Username label attached to the created passkey. */\n userName?: string\n /**\n * WebAuthn Relying Party ID. Omit to bind the passkey to the current origin's\n * effective domain (the default — no central/hardcoded domain). Set it to a\n * registrable parent domain to share the credential across subdomains of your\n * own deployment. The encrypted keyring is never portable across unrelated\n * domains.\n */\n rpId?: string\n /** IndexedDB database name. Defaults to `byok`. */\n dbName?: string\n}\n\n/**\n * Passkey-encrypted persistence. `ByokClient` treats this as `unlockable`, so\n * nothing is decrypted until the user calls `unlock()` (or saves a key, which\n * registers a passkey on first use). The derived key is cached in memory for\n * the session so repeated saves don't re-prompt.\n */\nexport function passkeyStorage(\n options: PasskeyStorageOptions = {},\n): KeyringStorage {\n const rpName = options.rpName ?? 'BYOK'\n const userName = options.userName ?? 'byok-keyring'\n const { rpId } = options\n const dbName = options.dbName ?? DEFAULT_DB\n\n let cachedKey: CryptoKey | null = null\n let cachedMeta: {\n credentialId: ArrayBuffer\n salt: Uint8Array<ArrayBuffer>\n } | null = null\n\n // Obtain the AES key, running exactly one WebAuthn ceremony if it isn't\n // already cached for this session (unlock if a passkey exists, else register).\n async function ensureKey(): Promise<{\n key: CryptoKey\n credentialId: ArrayBuffer\n salt: Uint8Array<ArrayBuffer>\n }> {\n if (cachedKey && cachedMeta) {\n return { key: cachedKey, ...cachedMeta }\n }\n const existing = await idbGet(dbName)\n if (existing) {\n requireUserActivation('unlock')\n const prf = await evaluatePrf(existing.credentialId, existing.salt)\n cachedKey = await deriveAesKey(prf)\n cachedMeta = {\n credentialId: existing.credentialId,\n salt: new Uint8Array(existing.salt),\n }\n } else {\n const reg = await registerPasskey(rpName, userName, rpId)\n // Registration can consume activation. Let the browser handle its\n // follow-up PRF ceremony rather than rejecting a valid new-key save.\n const prf = reg.prf ?? (await evaluatePrf(reg.credentialId, reg.salt))\n cachedKey = await deriveAesKey(prf)\n cachedMeta = { credentialId: reg.credentialId, salt: reg.salt }\n }\n return { key: cachedKey, ...cachedMeta }\n }\n\n return {\n id: 'passkey',\n label: 'Passkey-encrypted (this device)',\n persistent: true,\n unlockable: true,\n warning:\n 'Keys are encrypted with your passkey and unlocked with biometrics. ' +\n 'This protects saved keys if your device is stolen, but not against code ' +\n 'running on this page after you unlock.',\n peek: async () => {\n // Unencrypted last-4 sidecar — no key material, no unlock ceremony.\n const existing = await idbGet(dbName)\n return existing?.preview ?? {}\n },\n load: async () => {\n const existing = await idbGet(dbName)\n if (!existing) return {}\n const { key } = await ensureKey()\n return decryptKeyring(key, existing.iv, existing.ciphertext)\n },\n save: async (keys) => {\n const existing = await idbGet(dbName)\n const hasKeys = Object.values(keys).some(Boolean)\n // First save with an empty keyring is a no-op — avoids a passkey ceremony\n // when another storage tier writes an empty ring.\n if (!hasKeys && !existing) return\n\n const { key, credentialId, salt } = await ensureKey()\n const { iv, ciphertext } = await encryptKeyring(key, keys)\n await idbPut(dbName, {\n id: RECORD_ID,\n credentialId,\n salt: salt.buffer,\n iv,\n ciphertext,\n preview: previewOf(keys),\n })\n },\n clear: async () => {\n cachedKey = null\n cachedMeta = null\n await idbClear(dbName)\n },\n }\n}\n\n/**\n * Passkey-encrypted storage when WebAuthn is available in a secure context.\n * Otherwise session memory, with a warning — this is not an automatic PRF\n * fallback. First save still throws if the authenticator lacks PRF.\n */\nexport function defaultByokStorage(\n options?: PasskeyStorageOptions,\n): KeyringStorage {\n const secure =\n typeof globalThis.isSecureContext !== 'boolean' ||\n globalThis.isSecureContext\n if (!isPasskeyStorageSupported() || !secure) {\n return {\n ...memoryStorage(),\n warning:\n 'Passkeys are unavailable in this context. Keys stay in memory for this tab only.',\n }\n }\n return passkeyStorage(options)\n}\n"],"mappings":"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"}
|
|
@@ -144,6 +144,16 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
|
|
|
144
144
|
/** Constructor inputs `attach()` needs on every re-attach, not just the first. */
|
|
145
145
|
private readonly rejoinRunId;
|
|
146
146
|
private readonly cachesMessages;
|
|
147
|
+
/**
|
|
148
|
+
* Newest-window size from `history.pageSize`. Only set when
|
|
149
|
+
* `persistence === true`.
|
|
150
|
+
*/
|
|
151
|
+
private readonly historyPageSize;
|
|
152
|
+
private hasOlderMessages;
|
|
153
|
+
private olderMessagesCursor;
|
|
154
|
+
private readonly knownServerMessageIds;
|
|
155
|
+
private loadOlderMessagesInFlight;
|
|
156
|
+
private historyGeneration;
|
|
147
157
|
private devtoolsMounted;
|
|
148
158
|
private readonly callbacksRef;
|
|
149
159
|
constructor(options: ChatClientOptions<TTools, TContext, TInterrupts>);
|
|
@@ -470,6 +480,23 @@ export declare class ChatClient<TTools extends ReadonlyArray<AnyClientTool> = an
|
|
|
470
480
|
* Get current messages
|
|
471
481
|
*/
|
|
472
482
|
getMessages(): Array<UIMessage<TTools>>;
|
|
483
|
+
/**
|
|
484
|
+
* True when the last hydrate or older-page response said more messages exist.
|
|
485
|
+
*/
|
|
486
|
+
getHasOlderMessages(): boolean;
|
|
487
|
+
/**
|
|
488
|
+
* Fetch the next older window and put it in front of the painted messages.
|
|
489
|
+
*
|
|
490
|
+
* No-op when there is no older page, no cursor, or a page load is already
|
|
491
|
+
* running. A network failure rejects and leaves the painted messages as they
|
|
492
|
+
* are; {@link getHasOlderMessages} stays true.
|
|
493
|
+
*/
|
|
494
|
+
loadOlderMessages(): Promise<void>;
|
|
495
|
+
private resetHistoryPaging;
|
|
496
|
+
private applyHydrationPage;
|
|
497
|
+
private rememberServerMessageIds;
|
|
498
|
+
private rememberProcessorMessageIds;
|
|
499
|
+
private messagesForSend;
|
|
473
500
|
/**
|
|
474
501
|
* True when an interrupt (or another direct send) claimed the client during
|
|
475
502
|
* a drain. Read via a method so cross-await mutations are not constant-folded
|
package/dist/esm/chat-client.js
CHANGED
|
@@ -235,6 +235,16 @@ var ChatClient = class {
|
|
|
235
235
|
/** Constructor inputs `attach()` needs on every re-attach, not just the first. */
|
|
236
236
|
rejoinRunId;
|
|
237
237
|
cachesMessages;
|
|
238
|
+
/**
|
|
239
|
+
* Newest-window size from `history.pageSize`. Only set when
|
|
240
|
+
* `persistence === true`.
|
|
241
|
+
*/
|
|
242
|
+
historyPageSize;
|
|
243
|
+
hasOlderMessages = false;
|
|
244
|
+
olderMessagesCursor;
|
|
245
|
+
knownServerMessageIds = /* @__PURE__ */ new Set();
|
|
246
|
+
loadOlderMessagesInFlight = false;
|
|
247
|
+
historyGeneration = 0;
|
|
238
248
|
devtoolsMounted = false;
|
|
239
249
|
callbacksRef;
|
|
240
250
|
constructor(options) {
|
|
@@ -245,10 +255,12 @@ var ChatClient = class {
|
|
|
245
255
|
if (options.persistence === true) {
|
|
246
256
|
if (!options.threadId) throw new Error("[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).");
|
|
247
257
|
cachesMessages = false;
|
|
258
|
+
this.historyPageSize = options.history?.pageSize;
|
|
248
259
|
} else if (options.persistence) {
|
|
249
260
|
if (!options.threadId) throw new Error("[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).");
|
|
250
261
|
this.persistor = new ChatPersistor(options.persistence, options.threadId, (messages) => this.processor.setMessages(messages), (snapshot) => this.applyPersistedResume(snapshot));
|
|
251
|
-
|
|
262
|
+
this.historyPageSize = void 0;
|
|
263
|
+
} else this.historyPageSize = void 0;
|
|
252
264
|
this.bodyOption = options.body || {};
|
|
253
265
|
this.forwardedPropsOption = options.forwardedProps || {};
|
|
254
266
|
this.byok = options.byok;
|
|
@@ -506,16 +518,25 @@ var ChatClient = class {
|
|
|
506
518
|
if (!hydrate) return;
|
|
507
519
|
if (this.isLoading || this.abortController) return;
|
|
508
520
|
if (this.disposed) return;
|
|
521
|
+
const pageSize = this.historyPageSize;
|
|
522
|
+
const hydrateOptions = pageSize === void 0 ? void 0 : { limit: pageSize };
|
|
509
523
|
(async () => {
|
|
510
524
|
let result;
|
|
525
|
+
const generation = this.historyGeneration;
|
|
511
526
|
try {
|
|
512
|
-
result = await hydrate(this.threadId);
|
|
527
|
+
result = await hydrate(this.threadId, hydrateOptions);
|
|
513
528
|
} catch {
|
|
514
529
|
return;
|
|
515
530
|
}
|
|
531
|
+
if (generation !== this.historyGeneration) return;
|
|
516
532
|
if (this.disposed || !this.tailing) return;
|
|
517
533
|
if (this.isLoading || this.abortController) return;
|
|
518
|
-
|
|
534
|
+
this.applyHydrationPage(result.page);
|
|
535
|
+
if (result.messages.length > 0) {
|
|
536
|
+
const windowMessages = normalizeMessagesDates(result.messages);
|
|
537
|
+
this.processor.setMessages(windowMessages);
|
|
538
|
+
this.rememberServerMessageIds(windowMessages);
|
|
539
|
+
}
|
|
519
540
|
if (result.interrupts && result.interrupts.pending.length > 0) this.applyResumeSnapshot({
|
|
520
541
|
resumeState: {
|
|
521
542
|
threadId: this.threadId,
|
|
@@ -1275,7 +1296,7 @@ var ChatClient = class {
|
|
|
1275
1296
|
let activeDevtoolsRunId = null;
|
|
1276
1297
|
let runTerminalEventEmitted = false;
|
|
1277
1298
|
try {
|
|
1278
|
-
const messages = this.processor.getMessages();
|
|
1299
|
+
const messages = this.messagesForSend(this.processor.getMessages());
|
|
1279
1300
|
const clientTools = new Map(this.clientToolsRef.current);
|
|
1280
1301
|
const runtimeContext = this.context;
|
|
1281
1302
|
await this.callbacksRef.current.onResponse();
|
|
@@ -1368,6 +1389,7 @@ var ChatClient = class {
|
|
|
1368
1389
|
}
|
|
1369
1390
|
await this.drainPostStreamActions();
|
|
1370
1391
|
if (streamCompletedSuccessfully) {
|
|
1392
|
+
this.rememberProcessorMessageIds();
|
|
1371
1393
|
if (this.status !== "ready") this.setStatus("ready");
|
|
1372
1394
|
if (!this.messageQueueDraining) await this.drainQueue();
|
|
1373
1395
|
} else this.flushQueue();
|
|
@@ -1446,6 +1468,7 @@ var ChatClient = class {
|
|
|
1446
1468
|
} else if (this.activeRunIds.size > 0) this.resetSessionGenerating({ preserveClearedStreamTracking: true });
|
|
1447
1469
|
this.persistor?.beginClear();
|
|
1448
1470
|
this.processor.clearMessages();
|
|
1471
|
+
this.resetHistoryPaging();
|
|
1449
1472
|
this.discardPendingSends();
|
|
1450
1473
|
this.persistor?.remove();
|
|
1451
1474
|
this.lastResume = null;
|
|
@@ -1586,6 +1609,79 @@ var ChatClient = class {
|
|
|
1586
1609
|
return this.processor.getMessages();
|
|
1587
1610
|
}
|
|
1588
1611
|
/**
|
|
1612
|
+
* True when the last hydrate or older-page response said more messages exist.
|
|
1613
|
+
*/
|
|
1614
|
+
getHasOlderMessages() {
|
|
1615
|
+
return this.hasOlderMessages;
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Fetch the next older window and put it in front of the painted messages.
|
|
1619
|
+
*
|
|
1620
|
+
* No-op when there is no older page, no cursor, or a page load is already
|
|
1621
|
+
* running. A network failure rejects and leaves the painted messages as they
|
|
1622
|
+
* are; {@link getHasOlderMessages} stays true.
|
|
1623
|
+
*/
|
|
1624
|
+
async loadOlderMessages() {
|
|
1625
|
+
const hydrate = this.connection.hydrate;
|
|
1626
|
+
if (hydrate === void 0) return;
|
|
1627
|
+
if (this.disposed) return;
|
|
1628
|
+
if (!this.hasOlderMessages) return;
|
|
1629
|
+
const cursor = this.olderMessagesCursor;
|
|
1630
|
+
if (cursor === void 0) return;
|
|
1631
|
+
if (this.loadOlderMessagesInFlight) return;
|
|
1632
|
+
const hydrateOptions = { before: cursor };
|
|
1633
|
+
if (this.historyPageSize !== void 0) hydrateOptions.limit = this.historyPageSize;
|
|
1634
|
+
this.loadOlderMessagesInFlight = true;
|
|
1635
|
+
const generation = this.historyGeneration;
|
|
1636
|
+
try {
|
|
1637
|
+
const result = await hydrate(this.threadId, hydrateOptions);
|
|
1638
|
+
if (this.disposed) return;
|
|
1639
|
+
if (generation !== this.historyGeneration) return;
|
|
1640
|
+
const olderMessages = normalizeMessagesDates(result.messages);
|
|
1641
|
+
if (olderMessages.length === 0) throw new Error("Older page was empty");
|
|
1642
|
+
this.applyHydrationPage(result.page);
|
|
1643
|
+
this.processor.prependMessages(olderMessages);
|
|
1644
|
+
this.rememberServerMessageIds(olderMessages);
|
|
1645
|
+
} finally {
|
|
1646
|
+
this.loadOlderMessagesInFlight = false;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
resetHistoryPaging() {
|
|
1650
|
+
this.hasOlderMessages = false;
|
|
1651
|
+
this.olderMessagesCursor = void 0;
|
|
1652
|
+
this.knownServerMessageIds.clear();
|
|
1653
|
+
this.historyGeneration++;
|
|
1654
|
+
}
|
|
1655
|
+
applyHydrationPage(page) {
|
|
1656
|
+
if (page?.truncated === true) {
|
|
1657
|
+
this.hasOlderMessages = true;
|
|
1658
|
+
this.olderMessagesCursor = page.cursor;
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
this.hasOlderMessages = false;
|
|
1662
|
+
this.olderMessagesCursor = void 0;
|
|
1663
|
+
}
|
|
1664
|
+
rememberServerMessageIds(messages) {
|
|
1665
|
+
const ids = messages.map((message) => message.id);
|
|
1666
|
+
for (const id of ids) this.knownServerMessageIds.add(id);
|
|
1667
|
+
}
|
|
1668
|
+
rememberProcessorMessageIds() {
|
|
1669
|
+
if (this.historyPageSize === void 0) return;
|
|
1670
|
+
this.rememberServerMessageIds(this.processor.getMessages());
|
|
1671
|
+
}
|
|
1672
|
+
messagesForSend(messages) {
|
|
1673
|
+
if (this.historyPageSize === void 0) return messages;
|
|
1674
|
+
const unknownMessages = [];
|
|
1675
|
+
for (const message of messages) {
|
|
1676
|
+
if (this.knownServerMessageIds.has(message.id)) continue;
|
|
1677
|
+
unknownMessages.push(message);
|
|
1678
|
+
}
|
|
1679
|
+
if (unknownMessages.length > 0) return unknownMessages;
|
|
1680
|
+
const lastUserIndex = messages.findLastIndex((message) => message.role === "user");
|
|
1681
|
+
if (lastUserIndex === -1) return [];
|
|
1682
|
+
return messages.slice(lastUserIndex);
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1589
1685
|
* True when an interrupt (or another direct send) claimed the client during
|
|
1590
1686
|
* a drain. Read via a method so cross-await mutations are not constant-folded
|
|
1591
1687
|
* by control-flow analysis.
|