From d5b94ddf24e3a498342bdfceddb44f2599cc23ca Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 11 Apr 2026 10:25:59 +0100 Subject: [PATCH] feat(chat): add stopGeneration, fix onTurnComplete/onFinishPromise, add /chats/[chatId] route to ai-chat --- packages/trigger-sdk/src/v3/ai.ts | 9 +- packages/trigger-sdk/src/v3/chat.ts | 79 ++++++- pnpm-lock.yaml | 197 ++++++------------ references/ai-chat/src/app/actions.ts | 10 + .../ai-chat/src/app/chats/[chatId]/page.tsx | 31 +++ references/ai-chat/src/app/chats/layout.tsx | 16 ++ references/ai-chat/src/app/chats/page.tsx | 16 ++ references/ai-chat/src/app/page.tsx | 58 +----- .../src/components/chat-settings-context.tsx | 40 ++++ .../src/components/chat-sidebar-wrapper.tsx | 86 ++++++++ .../ai-chat/src/components/chat-view.tsx | 106 ++++++++++ references/ai-chat/src/components/chat.tsx | 12 +- 12 files changed, 458 insertions(+), 202 deletions(-) create mode 100644 references/ai-chat/src/app/chats/[chatId]/page.tsx create mode 100644 references/ai-chat/src/app/chats/layout.tsx create mode 100644 references/ai-chat/src/app/chats/page.tsx create mode 100644 references/ai-chat/src/components/chat-settings-context.tsx create mode 100644 references/ai-chat/src/components/chat-sidebar-wrapper.tsx create mode 100644 references/ai-chat/src/components/chat-view.tsx diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 9753adc75..564368212 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -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((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) diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 2e4d8955c..d75042547 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -607,6 +607,7 @@ export class TriggerChatTransport implements ChatTransport { reconnectToStream = async ( options: { chatId: string; + abortSignal?: AbortSignal | undefined; } & ChatRequestOptions ): Promise | null> => { const session = this.sessions.get(options.chatId); @@ -623,15 +624,88 @@ export class TriggerChatTransport implements ChatTransport { 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 }); + * + * + * ``` + */ + stopGeneration = async (chatId: string): Promise => { + 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"; - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6428103d3..a5d143bd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/references/ai-chat/src/app/actions.ts b/references/ai-chat/src/app/actions.ts index 3395bd8ce..29f974b8c 100644 --- a/references/ai-chat/src/app/actions.ts +++ b/references/ai-chat/src/app/actions.ts @@ -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 = diff --git a/references/ai-chat/src/app/chats/[chatId]/page.tsx b/references/ai-chat/src/app/chats/[chatId]/page.tsx new file mode 100644 index 000000000..e07c3c5e9 --- /dev/null +++ b/references/ai-chat/src/app/chats/[chatId]/page.tsx @@ -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 ( + + ); +} diff --git a/references/ai-chat/src/app/chats/layout.tsx b/references/ai-chat/src/app/chats/layout.tsx new file mode 100644 index 000000000..d76cd85f2 --- /dev/null +++ b/references/ai-chat/src/app/chats/layout.tsx @@ -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 ( + +
+ +
{children}
+
+
+ ); +} diff --git a/references/ai-chat/src/app/chats/page.tsx b/references/ai-chat/src/app/chats/page.tsx new file mode 100644 index 000000000..04cd57b70 --- /dev/null +++ b/references/ai-chat/src/app/chats/page.tsx @@ -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 ( +
+

No conversations yet. Start a new chat.

+
+ ); +} diff --git a/references/ai-chat/src/app/page.tsx b/references/ai-chat/src/app/page.tsx index 37ead39c5..679287039 100644 --- a/references/ai-chat/src/app/page.tsx +++ b/references/ai-chat/src/app/page.tsx @@ -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([]); - const [activeChatId, setActiveChatId] = useState(null); - const [initialMessages, setInitialMessages] = useState([]); - const [initialSessions, setInitialSessions] = useState< - Record - >({}); - const [loaded, setLoaded] = useState(false); - const [taskMode, setTaskMode] = useState("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 ( - - ); + redirect("/chats"); } diff --git a/references/ai-chat/src/components/chat-settings-context.tsx b/references/ai-chat/src/components/chat-settings-context.tsx new file mode 100644 index 000000000..7c8f2087f --- /dev/null +++ b/references/ai-chat/src/components/chat-settings-context.tsx @@ -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(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 {children}; +} + +export function useChatSettings() { + const ctx = useContext(ChatSettingsContext); + if (!ctx) throw new Error("useChatSettings must be used within ChatSettingsProvider"); + return ctx; +} diff --git a/references/ai-chat/src/components/chat-sidebar-wrapper.tsx b/references/ai-chat/src/components/chat-sidebar-wrapper.tsx new file mode 100644 index 000000000..d489818bb --- /dev/null +++ b/references/ai-chat/src/components/chat-sidebar-wrapper.tsx @@ -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 ( + + ); +} diff --git a/references/ai-chat/src/components/chat-view.tsx b/references/ai-chat/src/components/chat-view.tsx new file mode 100644 index 000000000..b1c7cb614 --- /dev/null +++ b/references/ai-chat/src/components/chat-view.tsx @@ -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 = {}; + 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 ( + 0 || !!initialSession} + model={model} + isNewChat={isNewChat} + session={activeSession} + dashboardUrl={process.env.NEXT_PUBLIC_TRIGGER_DASHBOARD_URL} + onFirstMessage={handleFirstMessage} + onMessagesChange={handleMessagesChange} + /> + ); +} diff --git a/references/ai-chat/src/components/chat.tsx b/references/ai-chat/src/components/chat.tsx index f3b700abf..c15b7012d 100644 --- a/references/ai-chat/src/components/chat.tsx +++ b/references/ai-chat/src/components/chat.tsx @@ -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([]); - 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;