feat(chat): add stopGeneration, fix onTurnComplete/onFinishPromise, add /chats/[chatId] route to ai-chat

This commit is contained in:
Eric Allam
2026-04-11 10:25:59 +01:00
parent 556266a8b2
commit d5b94ddf24
12 changed files with 458 additions and 202 deletions
+7 -2
View File
@@ -3199,8 +3199,13 @@ function chatAgent<
// Wait for onFinish to fire — on abort this may resolve slightly
// after pipeChat, since the stream's cancel() handler is async.
// Race with a timeout so a stop-abort that prevents onFinish from
// firing doesn't hang the turn loop indefinitely.
if (onFinishAttached) {
await onFinishPromise;
await Promise.race([
onFinishPromise,
new Promise<void>((r) => setTimeout(r, 2_000)),
]);
}
// Capture token usage from the streamText result (if available).
@@ -3539,7 +3544,7 @@ function chatAgent<
async () => {
await onTurnComplete({
...turnCompleteEvent,
lastEventId: turnCompleteResult.lastEventId,
lastEventId: turnCompleteResult?.lastEventId,
});
// Check if onTurnComplete replaced messages (compaction)
+76 -3
View File
@@ -607,6 +607,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
reconnectToStream = async (
options: {
chatId: string;
abortSignal?: AbortSignal | undefined;
} & ChatRequestOptions
): Promise<ReadableStream<UIMessageChunk> | null> => {
const session = this.sessions.get(options.chatId);
@@ -623,15 +624,88 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
const abortController = new AbortController();
this.activeStreams.set(options.chatId, abortController);
// When the AI SDK (or caller) provides an abortSignal (e.g. from
// useChat's stop()), use it as the stream signal so stop sends
// the stop input stream signal to the backend. Fall back to the
// internal controller for stream lifecycle management.
const abortSignal = options.abortSignal
? AbortSignal.any([options.abortSignal, abortController.signal])
: abortController.signal;
return this.subscribeToStream(
session.runId,
session.publicAccessToken,
abortController.signal,
abortSignal,
options.chatId,
{ sendStopOnAbort: false }
// Send stop when the caller's signal fires (user-initiated stop).
// The internal abortController is only for stream management.
{ sendStopOnAbort: !!options.abortSignal }
);
};
/**
* Stop the current generation for a chat session.
*
* Sends a stop signal to the backend task via input streams and closes
* the active SSE connection. Use this as your stop button handler —
* it works for both initial connections and reconnected streams
* (after page refresh).
*
* When the upstream AI SDK fix lands (passing `abortSignal` through
* `reconnectToStream`), `useChat`'s built-in `stop()` will also work.
* Until then, use this method for reliable stop behavior.
*
* @returns `true` if the stop signal was sent, `false` if there's no active session.
*
* @example
* ```tsx
* const transport = useTriggerChatTransport({ task: "my-chat", ... });
* const { messages, sendMessage } = useChat({ transport });
*
* <button onClick={() => transport.stopGeneration(chatId)}>Stop</button>
* ```
*/
stopGeneration = async (chatId: string): Promise<boolean> => {
const session = this.sessions.get(chatId);
if (!session?.runId) return false;
const sendStop = async (token: string) => {
const api = new ApiClient(this.baseURL, token);
await api.sendInputStream(session.runId, CHAT_STOP_STREAM_ID, { stop: true });
};
try {
await sendStop(session.publicAccessToken);
} catch (err) {
if (isRunPatAuthError(err) && this.renewRunAccessToken) {
const newToken = await this.renewRunPatForSession(chatId, session.runId);
if (newToken) {
try {
await sendStop(newToken);
} catch {
return false;
}
} else {
return false;
}
} else {
return false;
}
}
session.skipToTurnComplete = true;
// Abort the active stream (if any) to close the SSE connection
// and end the ReadableStream, causing useChat to finalize.
const activeStream = this.activeStreams.get(chatId);
if (activeStream) {
activeStream.abort();
this.activeStreams.delete(chatId);
}
return true;
};
/**
* Get the current session state for a chat, suitable for external persistence.
*
@@ -1140,4 +1214,3 @@ export {
type InferChatClientData,
type InferChatUIMessage,
} from "./chat-client.js";
+58 -139
View File
@@ -1865,7 +1865,7 @@ importers:
version: 4.0.14
ai:
specifier: ^6.0.0
version: 6.0.3(zod@3.25.76)
version: 6.0.116(zod@3.25.76)
defu:
specifier: ^6.1.4
version: 6.1.4
@@ -2616,7 +2616,7 @@ importers:
version: 7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.2.1)(fastify@5.4.0)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))
ai:
specifier: ^4.0.0
version: 4.0.0(react@18.3.1)(zod@3.25.76)
version: 4.3.19(react@18.3.1)(zod@3.25.76)
class-variance-authority:
specifier: ^0.7.0
version: 0.7.0
@@ -2883,7 +2883,7 @@ importers:
version: link:../../packages/trigger-sdk
ai:
specifier: ^5.0.76
version: 5.0.76(zod@3.25.76)
version: 5.0.155(zod@3.25.76)
next:
specifier: 15.5.6
version: 15.5.6(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -3020,14 +3020,8 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/gateway@2.0.0':
resolution: {integrity: sha512-Gj0PuawK7NkZuyYgO/h5kDK/l6hFOjhLdTq3/Lli1FTl47iGmwhH1IZQpAL3Z09BeFYWakcwUmn02ovIm2wy9g==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.2':
resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==}
'@ai-sdk/gateway@2.0.59':
resolution: {integrity: sha512-hmXtD2InGIMoHm3gf1yZM4GURS0/4ZL4ff9O0jkF1tXcKl9V/72omLZlwlOM9r7pgpr/65H7anaV2t8kbip0FQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@@ -3116,18 +3110,18 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@3.0.22':
resolution: {integrity: sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@3.0.3':
resolution: {integrity: sha512-kAxIw1nYmFW1g5TvE54ZB3eNtgZna0RnLjPUp1ltz1+t9xkXJIuDT4atrwfau9IbS0BOef38wqrI8CjFfQrxhw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/provider-utils@4.0.1':
resolution: {integrity: sha512-de2v8gH9zj47tRI38oSxhQIewmNc+OZjYIOOaMoVWKL65ERSav2PYYZHPSPCrfOeLMkv+Dyh8Y0QGwkO29wMWQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.17':
resolution: {integrity: sha512-oyCeFINTYK0B8ZGUBiQc05G5vytPlKSmTTtm19xfJuUgoi8zkvvRcoPQci4mSnyfpPn2XSFFDfsALG8uGcapfg==}
engines: {node: '>=18'}
@@ -3166,8 +3160,8 @@ packages:
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
engines: {node: '>=18'}
'@ai-sdk/provider@3.0.0':
resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==}
'@ai-sdk/provider@2.0.1':
resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==}
engines: {node: '>=18'}
'@ai-sdk/provider@3.0.5':
@@ -3178,18 +3172,6 @@ packages:
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
engines: {node: '>=18'}
'@ai-sdk/react@1.0.0':
resolution: {integrity: sha512-BDrZqQA07Btg64JCuhFvBgYV+tt2B8cXINzEqWknGoxqcwgdE8wSLG2gkXoLzyC2Rnj7oj0HHpOhLUxDCmoKZg==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
zod: ^3.0.0
peerDependenciesMeta:
react:
optional: true
zod:
optional: true
'@ai-sdk/react@1.2.12':
resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==}
engines: {node: '>=18'}
@@ -3216,15 +3198,6 @@ packages:
peerDependencies:
react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
'@ai-sdk/ui-utils@1.0.0':
resolution: {integrity: sha512-oXBDIM/0niWeTWyw77RVl505dNxBUDLLple7bTsqo2d3i1UKwGlzBUX8XqZsh7GbY7I6V05nlG0Y8iGlWxv1Aw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
peerDependenciesMeta:
zod:
optional: true
'@ai-sdk/ui-utils@1.2.1':
resolution: {integrity: sha512-BzvMbYm7LHBlbWuLlcG1jQh4eu14MGpz7L+wrGO1+F4oQ+O0fAjgUSNwPWGlZpKmg4NrcVq/QLmxiVJrx2R4Ew==}
engines: {node: '>=18'}
@@ -11623,14 +11596,6 @@ packages:
'@vanilla-extract/private@1.0.3':
resolution: {integrity: sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==}
'@vercel/oidc@3.0.3':
resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==}
engines: {node: '>= 20'}
'@vercel/oidc@3.0.5':
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
engines: {node: '>= 20'}
'@vercel/oidc@3.1.0':
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
engines: {node: '>= 20'}
@@ -11650,7 +11615,7 @@ packages:
'@vercel/postgres@0.10.0':
resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==}
engines: {node: '>=18.14'}
deprecated: '@vercel/postgres is deprecated. If you are setting up a new database, you can choose an alternate storage solution from the Vercel Marketplace. If you had an existing Vercel Postgres database, it should have been migrated to Neon as a native Vercel integration. You can find more details and the guide to migrate to Neon''s SDKs here: https://neon.com/docs/guides/vercel-postgres-transition-guide'
deprecated: '@vercel/postgres is deprecated. You can either choose an alternate storage solution from the Vercel Marketplace if you want to set up a new database. Or you can follow this guide to migrate your existing Vercel Postgres db: https://neon.com/docs/guides/vercel-postgres-transition-guide'
'@vercel/sdk@1.19.1':
resolution: {integrity: sha512-K4rmtUT6t1vX06tiY44ot8A7W1FKN7g/tMkE7yZghCgNQ8b30SzljBd4ni8RNp2pJzM/HrZmphRDeIArO7oZuw==}
@@ -11937,18 +11902,6 @@ packages:
ahocorasick@1.0.2:
resolution: {integrity: sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA==}
ai@4.0.0:
resolution: {integrity: sha512-cqf2GCaXnOPhUU+Ccq6i+5I0jDjnFkzfq7t6mc0SUSibSa1wDPn5J4p8+Joh2fDGDYZOJ44rpTW9hSs40rXNAw==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
zod: ^3.0.0
peerDependenciesMeta:
react:
optional: true
zod:
optional: true
ai@4.2.5:
resolution: {integrity: sha512-URJEslI3cgF/atdTJHtz+Sj0W1JTmiGmD3znw9KensL3qV605odktDim+GTazNJFPR4QaIu1lUio5b8RymvOjA==}
engines: {node: '>=18'}
@@ -11975,8 +11928,8 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4
ai@5.0.76:
resolution: {integrity: sha512-ZCxi1vrpyCUnDbtYrO/W8GLvyacV9689f00yshTIQ3mFFphbD7eIv40a2AOZBv3GGRA7SSRYIDnr56wcS/gyQg==}
ai@5.0.155:
resolution: {integrity: sha512-lq6PafJobpWyxnQofOukwIBwWNWKpf4AJMWISwHOOXEjMCcsHR1cKw92rQ/cy9O/tneQpP8uXgNWUDU+f+270A==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
@@ -11987,12 +11940,6 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ai@6.0.3:
resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ai@6.0.49:
resolution: {integrity: sha512-LABniBX/0R6Tv+iUK5keUZhZLaZUe4YjP5M2rZ4wAdZ8iKV3EfTAoJxuL1aaWTSJKIilKa9QUEkCgnp89/32bw==}
engines: {node: '>=18'}
@@ -14871,7 +14818,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
glob@9.3.5:
resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}
@@ -21185,18 +21132,11 @@ snapshots:
'@ai-sdk/provider-utils': 3.0.3(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/gateway@2.0.0(zod@3.25.76)':
'@ai-sdk/gateway@2.0.59(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.12(zod@3.25.76)
'@vercel/oidc': 3.0.3
zod: 3.25.76
'@ai-sdk/gateway@3.0.2(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.0
'@ai-sdk/provider-utils': 4.0.1(zod@3.25.76)
'@vercel/oidc': 3.0.5
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.22(zod@3.25.76)
'@vercel/oidc': 3.1.0
zod: 3.25.76
'@ai-sdk/gateway@3.0.22(zod@3.25.76)':
@@ -21288,20 +21228,20 @@ snapshots:
eventsource-parser: 3.0.6
zod: 3.25.76
'@ai-sdk/provider-utils@3.0.22(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.1
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 3.25.76
'@ai-sdk/provider-utils@3.0.3(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 3.25.76
zod-to-json-schema: 3.24.6(zod@3.25.76)
'@ai-sdk/provider-utils@4.0.1(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.0
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 3.25.76
zod-to-json-schema: 3.25.1(zod@3.25.76)
'@ai-sdk/provider-utils@4.0.17(zod@3.25.76)':
dependencies:
@@ -21344,7 +21284,7 @@ snapshots:
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@3.0.0':
'@ai-sdk/provider@2.0.1':
dependencies:
json-schema: 0.4.0
@@ -21356,16 +21296,6 @@ snapshots:
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@1.0.0(react@18.3.1)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 2.0.0(zod@3.25.76)
'@ai-sdk/ui-utils': 1.0.0(zod@3.25.76)
swr: 2.2.5(react@18.3.1)
throttleit: 2.1.0
optionalDependencies:
react: 18.3.1
zod: 3.25.76
'@ai-sdk/react@1.2.12(react@18.2.0)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.76)
@@ -21376,6 +21306,16 @@ snapshots:
optionalDependencies:
zod: 3.25.76
'@ai-sdk/react@1.2.12(react@18.3.1)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.76)
'@ai-sdk/ui-utils': 1.2.11(zod@3.25.76)
react: 18.3.1
swr: 2.2.5(react@18.3.1)
throttleit: 2.1.0
optionalDependencies:
zod: 3.25.76
'@ai-sdk/react@1.2.2(react@19.0.0)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 2.2.1(zod@3.25.76)
@@ -21406,27 +21346,19 @@ snapshots:
transitivePeerDependencies:
- zod
'@ai-sdk/ui-utils@1.0.0(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 1.0.0
'@ai-sdk/provider-utils': 2.0.0(zod@3.25.76)
zod-to-json-schema: 3.24.6(zod@3.25.76)
optionalDependencies:
zod: 3.25.76
'@ai-sdk/ui-utils@1.2.1(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 1.1.0
'@ai-sdk/provider-utils': 2.2.1(zod@3.25.76)
zod: 3.25.76
zod-to-json-schema: 3.24.6(zod@3.25.76)
zod-to-json-schema: 3.25.1(zod@3.25.76)
'@ai-sdk/ui-utils@1.2.11(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.76)
zod: 3.25.76
zod-to-json-schema: 3.24.6(zod@3.25.76)
zod-to-json-schema: 3.25.1(zod@3.25.76)
'@alloc/quick-lru@5.2.0': {}
@@ -32828,10 +32760,6 @@ snapshots:
'@vanilla-extract/private@1.0.3': {}
'@vercel/oidc@3.0.3': {}
'@vercel/oidc@3.0.5': {}
'@vercel/oidc@3.1.0': {}
'@vercel/otel@1.13.0(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))':
@@ -33214,19 +33142,6 @@ snapshots:
ahocorasick@1.0.2: {}
ai@4.0.0(react@18.3.1)(zod@3.25.76):
dependencies:
'@ai-sdk/provider': 1.0.0
'@ai-sdk/provider-utils': 2.0.0(zod@3.25.76)
'@ai-sdk/react': 1.0.0(react@18.3.1)(zod@3.25.76)
'@ai-sdk/ui-utils': 1.0.0(zod@3.25.76)
'@opentelemetry/api': 1.9.0
jsondiffpatch: 0.6.0
zod-to-json-schema: 3.24.6(zod@3.25.76)
optionalDependencies:
react: 18.3.1
zod: 3.25.76
ai@4.2.5(react@19.0.0)(zod@3.25.76):
dependencies:
'@ai-sdk/provider': 1.1.0
@@ -33251,6 +33166,18 @@ snapshots:
optionalDependencies:
react: 18.2.0
ai@4.3.19(react@18.3.1)(zod@3.25.76):
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.76)
'@ai-sdk/react': 1.2.12(react@18.3.1)(zod@3.25.76)
'@ai-sdk/ui-utils': 1.2.11(zod@3.25.76)
'@opentelemetry/api': 1.9.0
jsondiffpatch: 0.6.0
zod: 3.25.76
optionalDependencies:
react: 18.3.1
ai@5.0.14(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 1.0.6(zod@3.25.76)
@@ -33259,11 +33186,11 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ai@5.0.76(zod@3.25.76):
ai@5.0.155(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 2.0.0(zod@3.25.76)
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.12(zod@3.25.76)
'@ai-sdk/gateway': 2.0.59(zod@3.25.76)
'@ai-sdk/provider': 2.0.1
'@ai-sdk/provider-utils': 3.0.22(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
@@ -33275,14 +33202,6 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ai@6.0.3(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 3.0.2(zod@3.25.76)
'@ai-sdk/provider': 3.0.0
'@ai-sdk/provider-utils': 4.0.1(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ai@6.0.49(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 3.0.22(zod@3.25.76)
+10
View File
@@ -110,6 +110,16 @@ export async function deleteSessionAction(chatId: string) {
await prisma.chatSession.delete({ where: { id: chatId } }).catch(() => { });
}
export async function getSessionForChat(chatId: string) {
const session = await prisma.chatSession.findUnique({ where: { id: chatId } });
if (!session) return null;
return {
runId: session.runId,
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId ?? undefined,
};
}
export async function getAllSessions() {
const sessions = await prisma.chatSession.findMany();
const result: Record<string, { runId: string; publicAccessToken: string; lastEventId?: string }> =
@@ -0,0 +1,31 @@
import { getChatMessages, getSessionForChat, getChatList } from "@/app/actions";
import { ChatView } from "@/components/chat-view";
import { DEFAULT_MODEL } from "@/lib/models";
export default async function ChatPage({
params,
}: {
params: Promise<{ chatId: string }>;
}) {
const { chatId } = await params;
const [messages, session, chatList] = await Promise.all([
getChatMessages(chatId),
getSessionForChat(chatId),
getChatList(),
]);
const chatMeta = chatList.find((c) => c.id === chatId);
const isNewChat = !chatMeta;
const model = chatMeta?.model ?? DEFAULT_MODEL;
return (
<ChatView
chatId={chatId}
initialMessages={messages}
initialSession={session}
isNewChat={isNewChat}
model={model}
/>
);
}
@@ -0,0 +1,16 @@
import { getChatList } from "@/app/actions";
import { ChatSettingsProvider } from "@/components/chat-settings-context";
import { ChatSidebarWrapper } from "@/components/chat-sidebar-wrapper";
export default async function ChatsLayout({ children }: { children: React.ReactNode }) {
const chatList = await getChatList();
return (
<ChatSettingsProvider>
<main className="flex h-screen">
<ChatSidebarWrapper initialChatList={chatList} />
<div className="flex-1">{children}</div>
</main>
</ChatSettingsProvider>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { getChatList } from "@/app/actions";
import { redirect } from "next/navigation";
export default async function ChatsPage() {
const chatList = await getChatList();
if (chatList.length > 0) {
redirect(`/chats/${chatList[0]!.id}`);
}
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-gray-400">No conversations yet. Start a new chat.</p>
</div>
);
}
+2 -56
View File
@@ -1,59 +1,5 @@
"use client";
import type { ChatUiMessage } from "@/lib/chat-tools";
import { useEffect, useState } from "react";
import { ChatApp } from "@/components/chat-app";
import { getChatList, getChatMessages, getAllSessions } from "@/app/actions";
type ChatMeta = {
id: string;
title: string;
model: string;
createdAt: number;
updatedAt: number;
};
import { redirect } from "next/navigation";
export default function Home() {
const [chatList, setChatList] = useState<ChatMeta[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [initialMessages, setInitialMessages] = useState<ChatUiMessage[]>([]);
const [initialSessions, setInitialSessions] = useState<
Record<string, { runId: string; publicAccessToken: string; lastEventId?: string }>
>({});
const [loaded, setLoaded] = useState(false);
const [taskMode, setTaskMode] = useState<string>("ai-chat");
useEffect(() => {
async function load() {
const [list, sessions] = await Promise.all([getChatList(), getAllSessions()]);
setChatList(list);
setInitialSessions(sessions);
let firstChatId: string | null = null;
let firstMessages: ChatUiMessage[] = [];
if (list.length > 0) {
firstChatId = list[0]!.id;
firstMessages = await getChatMessages(firstChatId);
}
setActiveChatId(firstChatId);
setInitialMessages(firstMessages);
setLoaded(true);
}
load();
}, []);
if (!loaded) return null;
return (
<ChatApp
key={taskMode}
taskMode={taskMode}
onTaskModeChange={setTaskMode}
initialChatList={chatList}
initialActiveChatId={activeChatId}
initialMessages={initialMessages}
initialSessions={initialSessions}
/>
);
redirect("/chats");
}
@@ -0,0 +1,40 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
type ChatSettings = {
taskMode: string;
setTaskMode: (mode: string) => void;
preloadEnabled: boolean;
setPreloadEnabled: (enabled: boolean) => void;
idleTimeoutInSeconds: number;
setIdleTimeoutInSeconds: (seconds: number) => void;
};
const ChatSettingsContext = createContext<ChatSettings | null>(null);
export function ChatSettingsProvider({ children }: { children: ReactNode }) {
const [taskMode, setTaskMode] = useState("ai-chat");
const [preloadEnabled, setPreloadEnabled] = useState(true);
const [idleTimeoutInSeconds, setIdleTimeoutInSeconds] = useState(60);
const value: ChatSettings = {
taskMode,
setTaskMode,
preloadEnabled,
setPreloadEnabled,
idleTimeoutInSeconds,
setIdleTimeoutInSeconds,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Provider = ChatSettingsContext.Provider as any;
return <Provider value={value}>{children}</Provider>;
}
export function useChatSettings() {
const ctx = useContext(ChatSettingsContext);
if (!ctx) throw new Error("useChatSettings must be used within ChatSettingsProvider");
return ctx;
}
@@ -0,0 +1,86 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { ChatSidebar } from "@/components/chat-sidebar";
import { useChatSettings } from "@/components/chat-settings-context";
import { useState, useCallback, useEffect } from "react";
import { generateId } from "ai";
import { getChatList, deleteChat as deleteChatAction } from "@/app/actions";
type ChatMeta = {
id: string;
title: string;
model: string;
createdAt: number;
updatedAt: number;
};
export function ChatSidebarWrapper({
initialChatList,
}: {
initialChatList: ChatMeta[];
}) {
const router = useRouter();
const pathname = usePathname();
const [chatList, setChatList] = useState(initialChatList);
const {
taskMode,
setTaskMode,
preloadEnabled,
setPreloadEnabled,
idleTimeoutInSeconds,
setIdleTimeoutInSeconds,
} = useChatSettings();
// Extract active chatId from URL
const activeChatId =
pathname?.startsWith("/chats/") ? (pathname.split("/chats/")[1]?.split("/")[0] ?? null) : null;
const refreshChatList = useCallback(async () => {
const list = await getChatList();
setChatList(list);
}, []);
// Refresh chat list on navigation
useEffect(() => {
refreshChatList();
}, [pathname, refreshChatList]);
function handleSelectChat(id: string) {
router.push(`/chats/${id}`);
}
function handleNewChat() {
const id = generateId();
router.push(`/chats/${id}`);
}
async function handleDeleteChat(id: string) {
await deleteChatAction(id);
const list = await getChatList();
setChatList(list);
if (activeChatId === id) {
if (list.length > 0) {
router.push(`/chats/${list[0]!.id}`);
} else {
router.push("/chats");
}
}
}
return (
<ChatSidebar
chats={chatList}
activeChatId={activeChatId}
onSelectChat={handleSelectChat}
onNewChat={handleNewChat}
onDeleteChat={handleDeleteChat}
preloadEnabled={preloadEnabled}
onPreloadChange={setPreloadEnabled}
idleTimeoutInSeconds={idleTimeoutInSeconds}
onIdleTimeoutChange={setIdleTimeoutInSeconds}
taskMode={taskMode}
onTaskModeChange={setTaskMode}
/>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { ChatUiMessage } from "@/lib/chat-tools";
import { Chat } from "@/components/chat";
import { useChatSettings } from "@/components/chat-settings-context";
import { DEFAULT_MODEL } from "@/lib/models";
import {
getChatToken,
getChatList,
updateChatTitle,
deleteSessionAction,
renewRunAccessTokenForChat,
} from "@/app/actions";
import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
type SessionInfo = {
runId: string;
publicAccessToken: string;
lastEventId?: string;
};
type ChatViewProps = {
chatId: string;
initialMessages: ChatUiMessage[];
initialSession: SessionInfo | null;
isNewChat: boolean;
model: string;
};
export function ChatView({
chatId,
initialMessages,
initialSession,
isNewChat,
model,
}: ChatViewProps) {
const router = useRouter();
const { taskMode, preloadEnabled, idleTimeoutInSeconds } = useChatSettings();
const sessions: Record<string, SessionInfo> = {};
if (initialSession) {
sessions[chatId] = initialSession;
}
const handleSessionChange = useCallback((_id: string, session: SessionInfo | null) => {
if (!session) {
deleteSessionAction(_id);
}
}, []);
const transport = useTriggerChatTransport({
task: taskMode,
accessToken: (params) => getChatToken({ ...params, taskId: taskMode }),
renewRunAccessToken: ({ chatId, runId }) => renewRunAccessTokenForChat(chatId, runId),
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
sessions,
onSessionChange: handleSessionChange,
clientData: { userId: "user_123" },
triggerOptions: {
tags: ["user:user_123"],
},
});
// Preload new chats eagerly
useEffect(() => {
if (isNewChat && preloadEnabled) {
transport.preload(chatId, { idleTimeoutInSeconds });
}
}, [chatId, isNewChat, preloadEnabled, idleTimeoutInSeconds, transport]);
const handleFirstMessage = useCallback(
async (cId: string, text: string) => {
const title = text.slice(0, 40).trim() || "New chat";
await updateChatTitle(cId, title);
router.refresh();
},
[router]
);
const handleMessagesChange = useCallback(
async (_cId: string, _msgs: ChatUiMessage[]) => {
router.refresh();
},
[router]
);
const activeSession = initialSession ?? undefined;
return (
<Chat
key={chatId}
chatId={chatId}
initialMessages={initialMessages}
transport={transport}
resume={initialMessages.length > 0 || !!initialSession}
model={model}
isNewChat={isNewChat}
session={activeSession}
dashboardUrl={process.env.NEXT_PUBLIC_TRIGGER_DASHBOARD_URL}
onFirstMessage={handleFirstMessage}
onMessagesChange={handleMessagesChange}
/>
);
}
+10 -2
View File
@@ -5,7 +5,7 @@ import type { ChatUiMessage } from "@/lib/chat-tools";
import type { TriggerChatTransport } from "@trigger.dev/sdk/chat";
import type { CompactionChunkData } from "@trigger.dev/sdk/ai";
import { usePendingMessages } from "@trigger.dev/sdk/chat/react";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { MODEL_OPTIONS } from "@/lib/models";
@@ -267,13 +267,21 @@ export function Chat({
const turnCounter = useRef(0);
const [ttfbHistory, setTtfbHistory] = useState<TtfbEntry[]>([]);
const { messages, setMessages, sendMessage, stop, status, error } = useChat({
const { messages, setMessages, sendMessage, stop: aiStop, status, error } = useChat({
id: chatId,
messages: initialMessages,
transport,
resume: resumeProp,
});
// Use transport.stopGeneration for reliable stop after reconnect.
// Once the AI SDK passes abortSignal through reconnectToStream,
// aiStop() alone will suffice. Until then, this covers both cases.
const stop = useCallback(() => {
transport.stopGeneration(chatId);
aiStop();
}, [transport, chatId, aiStop]);
// Notify parent of first user message (for chat metadata creation)
useEffect(() => {
if (hasCalledFirstMessage.current) return;