build full example with persisting messages, adding necessary hooks, and documenting it all

This commit is contained in:
Eric Allam
2026-03-05 14:14:17 +00:00
parent 8640cf0d15
commit bfa3341d6b
19 changed files with 1086 additions and 41 deletions
+223 -3
View File
@@ -379,6 +379,47 @@ async function pipeChat(
* emits a control chunk and suspends via `messagesInput.wait()`. The frontend
* transport resumes the same run by sending the next message via input streams.
*/
/**
* Event passed to the `onChatStart` callback.
*/
export type ChatStartEvent = {
/** The unique identifier for the chat session. */
chatId: string;
/** The initial model-ready messages for this conversation. */
messages: ModelMessage[];
/** Custom data from the frontend (passed via `metadata` on `sendMessage()` or the transport). */
clientData: unknown;
};
/**
* Event passed to the `onTurnComplete` callback.
*/
export type TurnCompleteEvent = {
/** The unique identifier for the chat session. */
chatId: string;
/** The full accumulated conversation in model format (all turns so far). */
messages: ModelMessage[];
/**
* The full accumulated conversation in UI format (all turns so far).
* This is the format expected by `useChat` — store this for persistence.
*/
uiMessages: UIMessage[];
/**
* Only the new model messages from this turn (user message(s) + assistant response).
* Useful for appending to an existing conversation record.
*/
newMessages: ModelMessage[];
/**
* Only the new UI messages from this turn (user message(s) + assistant response).
* Useful for inserting individual message records instead of overwriting the full history.
*/
newUIMessages: UIMessage[];
/** The assistant's response for this turn (undefined if `pipeChat` was used manually). */
responseMessage: UIMessage | undefined;
/** The turn number (0-indexed). */
turn: number;
};
export type ChatTaskOptions<TIdentifier extends string> = Omit<
TaskOptions<TIdentifier, ChatTaskWirePayload, unknown>,
"run"
@@ -394,6 +435,35 @@ export type ChatTaskOptions<TIdentifier extends string> = Omit<
*/
run: (payload: ChatTaskRunPayload) => Promise<unknown>;
/**
* Called on the first turn (turn 0) of a new run, before the `run` function executes.
*
* Use this to create the chat record in your database when a new conversation starts.
*
* @example
* ```ts
* onChatStart: async ({ chatId, messages, clientData }) => {
* await db.chat.create({ data: { id: chatId, userId: clientData.userId } });
* }
* ```
*/
onChatStart?: (event: ChatStartEvent) => Promise<void> | void;
/**
* Called after each turn completes (after the response is captured, before waiting
* for the next message). Also fires on the final turn.
*
* Use this to persist the conversation to your database after each assistant response.
*
* @example
* ```ts
* onTurnComplete: async ({ chatId, messages }) => {
* await db.chat.update({ where: { id: chatId }, data: { messages } });
* }
* ```
*/
onTurnComplete?: (event: TurnCompleteEvent) => Promise<void> | void;
/**
* Maximum number of conversational turns (message round-trips) a single run
* will handle before ending. After this many turns the run completes
@@ -456,6 +526,8 @@ function chatTask<TIdentifier extends string>(
): Task<TIdentifier, ChatTaskWirePayload, unknown> {
const {
run: userRun,
onChatStart,
onTurnComplete,
maxTurns = 100,
turnTimeout = "1h",
warmTimeoutInSeconds = 30,
@@ -478,6 +550,10 @@ function chatTask<TIdentifier extends string>(
// user message(s) and the captured assistant response.
let accumulatedMessages: ModelMessage[] = [];
// Accumulated UI messages for persistence. Mirrors the model accumulator
// but in frontend-friendly UIMessage format (with parts, id, etc.).
let accumulatedUIMessages: UIMessage[] = [];
// Mutable reference to the current turn's stop controller so the
// stop input stream listener (registered once) can abort the right turn.
let currentStopController: AbortController | undefined;
@@ -549,15 +625,52 @@ function chatTask<TIdentifier extends string>(
// Turn 2+: only the new message(s) → appended to the accumulator.
const incomingModelMessages = await convertToModelMessages(uiMessages);
// Track new messages for this turn (user input + assistant response).
const turnNewModelMessages: ModelMessage[] = [];
const turnNewUIMessages: UIMessage[] = [];
if (turn === 0) {
accumulatedMessages = incomingModelMessages;
accumulatedUIMessages = [...uiMessages];
// On first turn, the "new" messages are just the last user message
// (the rest is history). We'll add the response after streaming.
if (uiMessages.length > 0) {
turnNewUIMessages.push(uiMessages[uiMessages.length - 1]!);
const lastModel = incomingModelMessages[incomingModelMessages.length - 1];
if (lastModel) turnNewModelMessages.push(lastModel);
}
} else if (currentWirePayload.trigger === "regenerate-message") {
// Regenerate: frontend sent full history with last assistant message
// removed. Reset the accumulator to match.
accumulatedMessages = incomingModelMessages;
accumulatedUIMessages = [...uiMessages];
// No new user messages for regenerate — just the response (added below)
} else {
// Submit: frontend sent only the new user message(s). Append to accumulator.
accumulatedMessages.push(...incomingModelMessages);
accumulatedUIMessages.push(...uiMessages);
turnNewModelMessages.push(...incomingModelMessages);
turnNewUIMessages.push(...uiMessages);
}
// Fire onChatStart on the first turn
if (turn === 0 && onChatStart) {
await tracer.startActiveSpan(
"onChatStart()",
async () => {
await onChatStart({
chatId: currentWirePayload.chatId,
messages: accumulatedMessages,
clientData: wireMetadata,
});
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
[SemanticInternalAttributes.COLLAPSED]: true,
},
}
);
}
// Captured by the onFinish callback below — works even on abort/stop.
@@ -602,11 +715,14 @@ function chatTask<TIdentifier extends string>(
// The onFinish callback fires even on abort/stop, so partial responses
// from stopped generation are captured correctly.
if (capturedResponseMessage) {
accumulatedUIMessages.push(capturedResponseMessage);
turnNewUIMessages.push(capturedResponseMessage);
try {
const responseModelMessages = await convertToModelMessages([
stripProviderMetadata(capturedResponseMessage),
]);
accumulatedMessages.push(...responseModelMessages);
turnNewModelMessages.push(...responseModelMessages);
} catch {
// Conversion failed — skip accumulation for this turn
}
@@ -618,6 +734,30 @@ function chatTask<TIdentifier extends string>(
if (runSignal.aborted) return "exit";
// Fire onTurnComplete after response capture
if (onTurnComplete) {
await tracer.startActiveSpan(
"onTurnComplete()",
async () => {
await onTurnComplete({
chatId: currentWirePayload.chatId,
messages: accumulatedMessages,
uiMessages: accumulatedUIMessages,
newMessages: turnNewModelMessages,
newUIMessages: turnNewUIMessages,
responseMessage: capturedResponseMessage,
turn,
});
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
[SemanticInternalAttributes.COLLAPSED]: true,
},
}
);
}
// Write turn-complete control chunk so frontend closes its stream
await writeTurnCompleteChunk(currentWirePayload.chatId);
@@ -629,9 +769,12 @@ function chatTask<TIdentifier extends string>(
// Phase 1: Keep the run warm for quick response to the next message.
// The run stays active (using compute) during this window.
if (warmTimeoutInSeconds > 0) {
const effectiveWarmTimeout =
(metadata.get(WARM_TIMEOUT_METADATA_KEY) as number | undefined) ?? warmTimeoutInSeconds;
if (effectiveWarmTimeout > 0) {
const warm = await messagesInput.once({
timeoutMs: warmTimeoutInSeconds * 1000,
timeoutMs: effectiveWarmTimeout * 1000,
spanName: "waiting (warm)",
});
@@ -643,8 +786,11 @@ function chatTask<TIdentifier extends string>(
}
// Phase 2: Suspend the task (frees compute) until the next message arrives
const effectiveTurnTimeout =
(metadata.get(TURN_TIMEOUT_METADATA_KEY) as string | undefined) ?? turnTimeout;
const next = await messagesInput.wait({
timeout: turnTimeout,
timeout: effectiveTurnTimeout,
spanName: "waiting (suspended)",
});
@@ -693,6 +839,74 @@ function chatTask<TIdentifier extends string>(
* const token = await chat.createAccessToken("my-chat");
* ```
*/
// ---------------------------------------------------------------------------
// Runtime configuration helpers
// ---------------------------------------------------------------------------
const TURN_TIMEOUT_METADATA_KEY = "chat.turnTimeout";
const WARM_TIMEOUT_METADATA_KEY = "chat.warmTimeout";
/**
* Override the turn timeout for subsequent turns in the current run.
*
* The turn timeout controls how long the run stays suspended (freeing compute)
* waiting for the next user message. When it expires, the run completes
* gracefully and the next message starts a fresh run.
*
* Call from inside a `chatTask` run function to adjust based on context.
*
* @param duration - A duration string (e.g. `"5m"`, `"1h"`, `"30s"`)
*
* @example
* ```ts
* run: async ({ messages, signal }) => {
* chat.setTurnTimeout("2h");
* return streamText({ model, messages, abortSignal: signal });
* }
* ```
*/
function setTurnTimeout(duration: string): void {
metadata.set(TURN_TIMEOUT_METADATA_KEY, duration);
}
/**
* Override the turn timeout in seconds for subsequent turns in the current run.
*
* @param seconds - Number of seconds to wait for the next message before ending the run
*
* @example
* ```ts
* run: async ({ messages, signal }) => {
* chat.setTurnTimeoutInSeconds(3600); // 1 hour
* return streamText({ model, messages, abortSignal: signal });
* }
* ```
*/
function setTurnTimeoutInSeconds(seconds: number): void {
metadata.set(TURN_TIMEOUT_METADATA_KEY, `${seconds}s`);
}
/**
* Override the warm timeout for subsequent turns in the current run.
*
* The warm timeout controls how long the run stays active (using compute)
* after each turn, waiting for the next message. During this window,
* responses are instant. After it expires, the run suspends.
*
* @param seconds - Number of seconds to stay warm (0 to suspend immediately)
*
* @example
* ```ts
* run: async ({ messages, signal }) => {
* chat.setWarmTimeoutInSeconds(60);
* return streamText({ model, messages, abortSignal: signal });
* }
* ```
*/
function setWarmTimeoutInSeconds(seconds: number): void {
metadata.set(WARM_TIMEOUT_METADATA_KEY, seconds);
}
export const chat = {
/** Create a chat task. See {@link chatTask}. */
task: chatTask,
@@ -700,6 +914,12 @@ export const chat = {
pipe: pipeChat,
/** Create a public access token for a chat task. See {@link createChatAccessToken}. */
createAccessToken: createChatAccessToken,
/** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
setTurnTimeout,
/** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */
setTurnTimeoutInSeconds,
/** Override the warm timeout at runtime. See {@link setWarmTimeoutInSeconds}. */
setWarmTimeoutInSeconds,
};
/**
+11 -1
View File
@@ -23,7 +23,7 @@
* ```
*/
import { useRef } from "react";
import { useEffect, useRef } from "react";
import {
TriggerChatTransport,
type TriggerChatTransportOptions,
@@ -57,6 +57,9 @@ export type UseTriggerChatTransportOptions<TTask extends AnyTask = AnyTask> = Om
* For dynamic access tokens, pass a function — it will be called on each
* request without needing to recreate the transport.
*
* The `onSessionChange` callback is kept in a ref so the transport always
* calls the latest version without needing to be recreated.
*
* @example
* ```tsx
* import { useChat } from "@ai-sdk/react";
@@ -80,5 +83,12 @@ export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
if (ref.current === null) {
ref.current = new TriggerChatTransport(options);
}
// Keep onSessionChange up to date without recreating the transport
const { onSessionChange } = options;
useEffect(() => {
ref.current?.setOnSessionChange(onSessionChange);
}, [onSessionChange]);
return ref.current;
}
+183
View File
@@ -1727,4 +1727,187 @@ describe("TriggerChatTransport", () => {
expect(triggerCallCount).toBe(2);
});
});
describe("onSessionChange", () => {
it("should fire when a new session is created", async () => {
const onSessionChange = vi.fn();
const triggerRunId = "run_session_new";
const publicToken = "pub_session_new";
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (urlStr.includes("/trigger")) {
return new Response(JSON.stringify({ id: triggerRunId }), {
status: 200,
headers: {
"content-type": "application/json",
"x-trigger-jwt": publicToken,
},
});
}
if (urlStr.includes("/realtime/v1/streams/")) {
const chunks = [
...sampleChunks,
{ type: "__trigger_turn_complete" },
];
return new Response(createSSEStream(sseEncode(chunks)), {
status: 200,
headers: {
"content-type": "text/event-stream",
"X-Stream-Version": "v1",
},
});
}
throw new Error(`Unexpected fetch URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-task",
accessToken: "token",
baseURL: "https://api.test.trigger.dev",
onSessionChange,
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [createUserMessage("Hello")],
abortSignal: undefined,
});
// Session created notification should have fired
expect(onSessionChange).toHaveBeenCalledWith("chat-1", {
runId: triggerRunId,
publicAccessToken: publicToken,
lastEventId: undefined,
});
// Consume stream
const reader = stream.getReader();
while (!(await reader.read()).done) {}
// Should also fire with updated lastEventId on turn complete
const lastCall = onSessionChange.mock.calls[onSessionChange.mock.calls.length - 1]!;
expect(lastCall![0]).toBe("chat-1");
expect(lastCall![1]).not.toBeNull();
expect(lastCall![1].lastEventId).toBeDefined();
});
it("should fire with null when session is deleted (stream ends naturally)", async () => {
const onSessionChange = vi.fn();
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (urlStr.includes("/trigger")) {
return new Response(JSON.stringify({ id: "run_end" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-trigger-jwt": "pub_end",
},
});
}
if (urlStr.includes("/realtime/v1/streams/")) {
// No turn-complete chunk — stream ends naturally (run completed)
return new Response(createSSEStream(sseEncode(sampleChunks)), {
status: 200,
headers: {
"content-type": "text/event-stream",
"X-Stream-Version": "v1",
},
});
}
throw new Error(`Unexpected fetch URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-task",
accessToken: "token",
baseURL: "https://api.test.trigger.dev",
onSessionChange,
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "chat-end",
messageId: undefined,
messages: [createUserMessage("Hello")],
abortSignal: undefined,
});
// Consume the stream fully
const reader = stream.getReader();
while (!(await reader.read()).done) {}
// Session should have been created then deleted
expect(onSessionChange).toHaveBeenCalledWith("chat-end", expect.objectContaining({
runId: "run_end",
}));
expect(onSessionChange).toHaveBeenCalledWith("chat-end", null);
});
it("should be updatable via setOnSessionChange", async () => {
const onSessionChange1 = vi.fn();
const onSessionChange2 = vi.fn();
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
const urlStr = typeof url === "string" ? url : url.toString();
if (urlStr.includes("/trigger")) {
return new Response(JSON.stringify({ id: "run_update" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-trigger-jwt": "pub_update",
},
});
}
if (urlStr.includes("/realtime/v1/streams/")) {
const chunks = [
...sampleChunks,
{ type: "__trigger_turn_complete" },
];
return new Response(createSSEStream(sseEncode(chunks)), {
status: 200,
headers: {
"content-type": "text/event-stream",
"X-Stream-Version": "v1",
},
});
}
throw new Error(`Unexpected fetch URL: ${urlStr}`);
});
const transport = new TriggerChatTransport({
task: "my-task",
accessToken: "token",
baseURL: "https://api.test.trigger.dev",
onSessionChange: onSessionChange1,
});
// Update the callback before sending
transport.setOnSessionChange(onSessionChange2);
await transport.sendMessages({
trigger: "submit-message",
chatId: "chat-update",
messageId: undefined,
messages: [createUserMessage("Hello")],
abortSignal: undefined,
});
// Only onSessionChange2 should have been called
expect(onSessionChange1).not.toHaveBeenCalled();
expect(onSessionChange2).toHaveBeenCalled();
});
});
});
+136 -2
View File
@@ -100,6 +100,57 @@ export type TriggerChatTransportOptions = {
* ```
*/
metadata?: Record<string, unknown>;
/**
* Restore active chat sessions from external storage (e.g. localStorage).
*
* After a page refresh, pass previously persisted sessions here so the
* transport can reconnect to existing runs instead of starting new ones.
* Use `getSession()` to retrieve session state for persistence.
*
* @example
* ```ts
* new TriggerChatTransport({
* task: "my-chat",
* accessToken,
* sessions: {
* "chat-abc": { runId: "run_123", publicAccessToken: "...", lastEventId: "42" },
* },
* });
* ```
*/
sessions?: Record<string, { runId: string; publicAccessToken: string; lastEventId?: string }>;
/**
* Called whenever a chat session's state changes.
*
* Fires when:
* - A new session is created (after triggering a task)
* - A turn completes (lastEventId updated)
* - A session is removed (run ended or input stream send failed) — `session` will be `null`
*
* Use this to persist session state for reconnection after page refreshes,
* without needing to call `getSession()` manually.
*
* @example
* ```ts
* new TriggerChatTransport({
* task: "my-chat",
* accessToken,
* onSessionChange: (chatId, session) => {
* if (session) {
* localStorage.setItem(`session:${chatId}`, JSON.stringify(session));
* } else {
* localStorage.removeItem(`session:${chatId}`);
* }
* },
* });
* ```
*/
onSessionChange?: (
chatId: string,
session: { runId: string; publicAccessToken: string; lastEventId?: string } | null
) => void;
};
/**
@@ -151,6 +202,12 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
private readonly extraHeaders: Record<string, string>;
private readonly streamTimeoutSeconds: number;
private readonly defaultMetadata: Record<string, unknown> | undefined;
private _onSessionChange:
| ((
chatId: string,
session: { runId: string; publicAccessToken: string; lastEventId?: string } | null
) => void)
| undefined;
private sessions: Map<string, ChatSessionState> = new Map();
@@ -165,6 +222,18 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
this.extraHeaders = options.headers ?? {};
this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? DEFAULT_STREAM_TIMEOUT_SECONDS;
this.defaultMetadata = options.metadata;
this._onSessionChange = options.onSessionChange;
// Restore sessions from external storage
if (options.sessions) {
for (const [chatId, session] of Object.entries(options.sessions)) {
this.sessions.set(chatId, {
runId: session.runId,
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId,
});
}
}
}
sendMessages = async (
@@ -216,6 +285,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
} catch {
// If sending fails (run died, etc.), fall through to trigger a new run.
this.sessions.delete(chatId);
this.notifySessionChange(chatId, null);
}
}
@@ -236,10 +306,12 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
? (triggerResponse as { publicAccessToken?: string }).publicAccessToken
: undefined;
this.sessions.set(chatId, {
const newSession: ChatSessionState = {
runId,
publicAccessToken: publicAccessToken ?? currentToken,
});
};
this.sessions.set(chatId, newSession);
this.notifySessionChange(chatId, newSession);
return this.subscribeToStream(
runId,
publicAccessToken ?? currentToken,
@@ -261,6 +333,62 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
return this.subscribeToStream(session.runId, session.publicAccessToken, undefined, options.chatId);
};
/**
* Get the current session state for a chat, suitable for external persistence.
*
* Returns `undefined` if no active session exists for this chatId.
* Persist the returned value to localStorage so it can be restored
* after a page refresh via `restoreSession()`.
*
* @example
* ```ts
* const session = transport.getSession(chatId);
* if (session) {
* localStorage.setItem(`session:${chatId}`, JSON.stringify(session));
* }
* ```
*/
getSession = (chatId: string): { runId: string; publicAccessToken: string; lastEventId?: string } | undefined => {
const session = this.sessions.get(chatId);
if (!session) return undefined;
return {
runId: session.runId,
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId,
};
};
/**
* Update the `onSessionChange` callback.
* Useful for React hooks that need to update the callback without recreating the transport.
*/
setOnSessionChange(
callback:
| ((
chatId: string,
session: { runId: string; publicAccessToken: string; lastEventId?: string } | null
) => void)
| undefined
): void {
this._onSessionChange = callback;
}
private notifySessionChange(
chatId: string,
session: ChatSessionState | null
): void {
if (!this._onSessionChange) return;
if (session) {
this._onSessionChange(chatId, {
runId: session.runId,
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId,
});
} else {
this._onSessionChange(chatId, null);
}
}
private subscribeToStream(
runId: string,
accessToken: string,
@@ -331,6 +459,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
// the next message via input streams.
if (chatId && !combinedSignal.aborted) {
this.sessions.delete(chatId);
this.notifySessionChange(chatId, null);
}
controller.close();
return;
@@ -348,6 +477,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
session.lastEventId = value.id;
}
// Guard against heartbeat or malformed SSE events
if (value.chunk != null && typeof value.chunk === "object") {
const chunk = value.chunk as Record<string, unknown>;
@@ -363,6 +493,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
}
if (chunk.type === "__trigger_turn_complete" && chatId) {
// Notify with updated lastEventId before closing
if (session) {
this.notifySessionChange(chatId, session);
}
internalAbort.abort();
try {
controller.close();
+1
View File
@@ -0,0 +1 @@
lib/generated/
+9 -1
View File
@@ -6,15 +6,22 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"dev:trigger": "trigger dev"
"dev:trigger": "trigger dev",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:generate": "prisma generate",
"postinstall": "prisma generate"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.0",
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@trigger.dev/sdk": "workspace:*",
"ai": "^6.0.0",
"next": "15.3.3",
"pg": "^8.16.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"streamdown": "^2.3.0",
@@ -27,6 +34,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"prisma": "^7.4.2",
"trigger.dev": "workspace:*",
"typescript": "^5"
}
+12
View File
@@ -0,0 +1,12 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "Chat" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"messages" JSONB NOT NULL DEFAULT '[]',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Chat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatSession" (
"id" TEXT NOT NULL,
"runId" TEXT NOT NULL,
"publicAccessToken" TEXT NOT NULL,
"lastEventId" TEXT,
CONSTRAINT "ChatSession_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+23
View File
@@ -0,0 +1,23 @@
generator client {
provider = "prisma-client"
output = "../lib/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Chat {
id String @id
title String
messages Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ChatSession {
id String @id // chatId
runId String
publicAccessToken String
lastEventId String?
}
+74
View File
@@ -2,5 +2,79 @@
import { chat } from "@trigger.dev/sdk/ai";
import type { aiChat } from "@/trigger/chat";
import { prisma } from "@/lib/prisma";
export const getChatToken = async () => chat.createAccessToken<typeof aiChat>("ai-chat");
export async function getChatList() {
const chats = await prisma.chat.findMany({
select: { id: true, title: true, createdAt: true, updatedAt: true },
orderBy: { updatedAt: "desc" },
});
return chats.map((c) => ({
id: c.id,
title: c.title,
createdAt: c.createdAt.getTime(),
updatedAt: c.updatedAt.getTime(),
}));
}
export async function getChatMessages(chatId: string) {
const found = await prisma.chat.findUnique({ where: { id: chatId } });
if (!found) return [];
return found.messages as any[];
}
export async function saveChatMessages(chatId: string, messages: unknown[]) {
await prisma.chat.update({
where: { id: chatId },
data: { messages: messages as any },
}).catch(() => {});
}
export async function deleteChat(chatId: string) {
await prisma.chat.delete({ where: { id: chatId } }).catch(() => {});
await prisma.chatSession.delete({ where: { id: chatId } }).catch(() => {});
}
export async function updateChatTitle(chatId: string, title: string) {
await prisma.chat.update({ where: { id: chatId }, data: { title } }).catch(() => {});
}
export async function saveSessionAction(
chatId: string,
session: { runId: string; publicAccessToken: string; lastEventId?: string }
) {
await prisma.chatSession.upsert({
where: { id: chatId },
create: {
id: chatId,
runId: session.runId,
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId,
},
update: {
runId: session.runId,
publicAccessToken: session.publicAccessToken,
lastEventId: session.lastEventId,
},
});
}
export async function deleteSessionAction(chatId: string) {
await prisma.chatSession.delete({ where: { id: chatId } }).catch(() => {});
}
export async function getAllSessions() {
const sessions = await prisma.chatSession.findMany();
const result: Record<string, { runId: string; publicAccessToken: string; lastEventId?: string }> =
{};
for (const s of sessions) {
result[s.id] = {
runId: s.runId,
publicAccessToken: s.publicAccessToken,
lastEventId: s.lastEventId ?? undefined,
};
}
return result;
}
+53 -4
View File
@@ -1,9 +1,58 @@
import { Chat } from "@/components/chat";
"use client";
import type { UIMessage } from "ai";
import { useEffect, useState } from "react";
import { ChatApp } from "@/components/chat-app";
import {
getChatList,
getChatMessages,
getAllSessions,
} from "@/app/actions";
type ChatMeta = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
};
export default function Home() {
const [chatList, setChatList] = useState<ChatMeta[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
const [initialMessages, setInitialMessages] = useState<UIMessage[]>([]);
const [initialSessions, setInitialSessions] = useState<
Record<string, { runId: string; publicAccessToken: string; lastEventId?: string }>
>({});
const [loaded, setLoaded] = useState(false);
useEffect(() => {
async function load() {
const [list, sessions] = await Promise.all([getChatList(), getAllSessions()]);
setChatList(list);
setInitialSessions(sessions);
let firstChatId: string | null = null;
let firstMessages: UIMessage[] = [];
if (list.length > 0) {
firstChatId = list[0]!.id;
firstMessages = await getChatMessages(firstChatId);
}
setActiveChatId(firstChatId);
setInitialMessages(firstMessages);
setLoaded(true);
}
load();
}, []);
if (!loaded) return null;
return (
<main className="flex h-screen flex-col">
<Chat />
</main>
<ChatApp
initialChatList={chatList}
initialActiveChatId={activeChatId}
initialMessages={initialMessages}
initialSessions={initialSessions}
/>
);
}
@@ -0,0 +1,155 @@
"use client";
import type { UIMessage } from "ai";
import { generateId } from "ai";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import { useCallback, useEffect, useState } from "react";
import { Chat } from "@/components/chat";
import { ChatSidebar } from "@/components/chat-sidebar";
import {
getChatToken,
getChatList,
getChatMessages,
deleteChat as deleteChatAction,
updateChatTitle,
saveSessionAction,
deleteSessionAction,
saveChatMessages,
} from "@/app/actions";
type ChatMeta = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
};
type ChatAppProps = {
initialChatList: ChatMeta[];
initialActiveChatId: string | null;
initialMessages: UIMessage[];
initialSessions: Record<
string,
{ runId: string; publicAccessToken: string; lastEventId?: string }
>;
};
export function ChatApp({
initialChatList,
initialActiveChatId,
initialMessages,
initialSessions,
}: ChatAppProps) {
const [chatList, setChatList] = useState<ChatMeta[]>(initialChatList);
const [activeChatId, setActiveChatId] = useState<string | null>(initialActiveChatId);
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
const handleSessionChange = useCallback(
(
chatId: string,
session: { runId: string; publicAccessToken: string; lastEventId?: string } | null
) => {
if (session) {
saveSessionAction(chatId, session);
} else {
deleteSessionAction(chatId);
}
},
[]
);
const transport = useTriggerChatTransport({
task: "ai-chat",
accessToken: getChatToken,
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
sessions: initialSessions,
onSessionChange: handleSessionChange,
});
// Load messages when active chat changes
useEffect(() => {
if (!activeChatId) {
setMessages([]);
return;
}
// Don't reload if we already have the initial messages for the initial chat
if (activeChatId === initialActiveChatId && messages === initialMessages) {
return;
}
getChatMessages(activeChatId).then(setMessages);
}, [activeChatId]);
function handleNewChat() {
const id = generateId();
setActiveChatId(id);
setMessages([]);
}
function handleSelectChat(id: string) {
setActiveChatId(id);
}
async function handleDeleteChat(id: string) {
await deleteChatAction(id);
const list = await getChatList();
setChatList(list);
if (activeChatId === id) {
if (list.length > 0) {
setActiveChatId(list[0]!.id);
} else {
setActiveChatId(null);
}
}
}
const handleFirstMessage = useCallback(async (chatId: string, text: string) => {
const title = text.slice(0, 40).trim() || "New chat";
await updateChatTitle(chatId, title);
const list = await getChatList();
setChatList(list);
}, []);
const handleMessagesChange = useCallback(async (_chatId: string, _messages: UIMessage[]) => {
// Messages are persisted server-side via onTurnComplete.
// Refresh the chat list to update timestamps.
const list = await getChatList();
setChatList(list);
}, []);
return (
<main className="flex h-screen">
<ChatSidebar
chats={chatList}
activeChatId={activeChatId}
onSelectChat={handleSelectChat}
onNewChat={handleNewChat}
onDeleteChat={handleDeleteChat}
/>
<div className="flex-1">
{activeChatId ? (
<Chat
key={activeChatId}
chatId={activeChatId}
initialMessages={messages}
transport={transport}
onFirstMessage={handleFirstMessage}
onMessagesChange={handleMessagesChange}
/>
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-sm text-gray-400">No conversation selected</p>
<button
type="button"
onClick={handleNewChat}
className="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Start a new chat
</button>
</div>
</div>
)}
</div>
</main>
);
}
@@ -0,0 +1,82 @@
"use client";
type ChatMeta = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
};
function timeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
type ChatSidebarProps = {
chats: ChatMeta[];
activeChatId: string | null;
onSelectChat: (id: string) => void;
onNewChat: () => void;
onDeleteChat: (id: string) => void;
};
export function ChatSidebar({
chats,
activeChatId,
onSelectChat,
onNewChat,
onDeleteChat,
}: ChatSidebarProps) {
const sorted = [...chats].sort((a, b) => b.updatedAt - a.updatedAt);
return (
<div className="flex h-full w-64 shrink-0 flex-col border-r border-gray-200 bg-gray-50">
<div className="p-3">
<button
type="button"
onClick={onNewChat}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
+ New Chat
</button>
</div>
<div className="flex-1 overflow-y-auto">
{sorted.length === 0 && (
<p className="px-3 py-8 text-center text-xs text-gray-400">No conversations yet</p>
)}
{sorted.map((chat) => (
<button
key={chat.id}
type="button"
onClick={() => onSelectChat(chat.id)}
className={`group flex w-full items-start gap-2 px-3 py-2.5 text-left text-sm hover:bg-gray-100 ${
activeChatId === chat.id ? "bg-white" : ""
}`}
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-gray-800">{chat.title}</div>
<div className="text-[10px] text-gray-400">{timeAgo(chat.updatedAt)}</div>
</div>
<span
onClick={(e) => {
e.stopPropagation();
onDeleteChat(chat.id);
}}
className="mt-0.5 hidden shrink-0 rounded p-0.5 text-xs text-gray-400 hover:bg-red-100 hover:text-red-600 group-hover:inline-block"
>
&times;
</span>
</button>
))}
</div>
</div>
);
}
+50 -27
View File
@@ -1,12 +1,11 @@
"use client";
import type { UIMessage } from "ai";
import { useChat } from "@ai-sdk/react";
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
import type { TriggerChatTransport } from "@trigger.dev/sdk/chat";
import { useEffect, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { getChatToken } from "@/app/actions";
import { MODEL_OPTIONS, DEFAULT_MODEL } from "@/trigger/chat";
import type { aiChat } from "@/trigger/chat";
import { MODEL_OPTIONS, DEFAULT_MODEL } from "@/lib/models";
function ToolInvocation({ part }: { part: any }) {
const [expanded, setExpanded] = useState(false);
@@ -71,41 +70,73 @@ function ToolInvocation({ part }: { part: any }) {
);
}
export function Chat() {
type ChatProps = {
chatId: string;
initialMessages: UIMessage[];
transport: TriggerChatTransport;
onFirstMessage?: (chatId: string, text: string) => void;
onMessagesChange?: (chatId: string, messages: UIMessage[]) => void;
};
export function Chat({
chatId,
initialMessages,
transport,
onFirstMessage,
onMessagesChange,
}: ChatProps) {
const [input, setInput] = useState("");
const [model, setModel] = useState(DEFAULT_MODEL);
// Track which model was used for each assistant message (keyed by the preceding user message ID)
const modelByUserMsgId = useRef<Map<string, string>>(new Map());
const transport = useTriggerChatTransport<typeof aiChat>({
task: "ai-chat",
accessToken: getChatToken,
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
});
const hasCalledFirstMessage = useRef(false);
const { messages, sendMessage, stop, status, error } = useChat({
id: chatId,
messages: initialMessages,
transport,
});
// Notify parent of first user message (for chat metadata creation)
useEffect(() => {
if (hasCalledFirstMessage.current) return;
const firstUser = messages.find((m) => m.role === "user");
if (firstUser) {
hasCalledFirstMessage.current = true;
const text = firstUser.parts
.filter((p: any) => p.type === "text")
.map((p: any) => p.text)
.join(" ");
onFirstMessage?.(chatId, text);
}
}, [messages, chatId, onFirstMessage]);
// Pending message to send after the current turn completes
const [pendingMessage, setPendingMessage] = useState<{ text: string; model: string } | null>(null);
// Auto-send the pending message when the turn completes
// Handle turn completion: persist messages and auto-send pending message
const prevStatus = useRef(status);
useEffect(() => {
if (prevStatus.current === "streaming" && status === "ready" && pendingMessage) {
const turnCompleted = prevStatus.current === "streaming" && status === "ready";
prevStatus.current = status;
if (!turnCompleted) return;
// Persist messages when a turn completes — this ensures the final assistant
// message content is saved (not the empty placeholder from mid-stream).
if (messages.length > 0) {
onMessagesChange?.(chatId, messages);
}
// Auto-send the pending message
if (pendingMessage) {
const { text, model: pendingMsgModel } = pendingMessage;
setPendingMessage(null);
pendingModel.current = pendingMsgModel;
sendMessage({ text }, { metadata: { model: pendingMsgModel } });
}
prevStatus.current = status;
}, [status, sendMessage, pendingMessage]);
}, [status, messages, chatId, onMessagesChange, sendMessage, pendingMessage]);
// Build a map of assistant message index -> model used
// Each assistant message follows a user message, so we track by position
function getModelForAssistantAt(index: number): string | undefined {
// Walk backwards to find the preceding user message
for (let i = index - 1; i >= 0; i--) {
if (messages[i]?.role === "user") {
return modelByUserMsgId.current.get(messages[i].id);
@@ -114,16 +145,13 @@ export function Chat() {
return undefined;
}
// When sending, record which model is selected for this user message
const originalSendMessage = sendMessage;
function trackedSendMessage(msg: Parameters<typeof sendMessage>[0], opts?: Parameters<typeof sendMessage>[1]) {
// We'll track it after the message appears — use a ref to store the pending model
pendingModel.current = model;
originalSendMessage(msg, opts);
}
const pendingModel = useRef<string>(model);
// Track model for new user messages as they appear
const trackedUserIds = useRef<Set<string>>(new Set());
for (const msg of messages) {
if (msg.role === "user" && !trackedUserIds.current.has(msg.id)) {
@@ -146,7 +174,6 @@ export function Chat() {
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<div className={`max-w-[80%] ${message.role === "user" ? "" : "w-full"}`}>
{/* Model badge for assistant messages */}
{message.role === "assistant" && (
<div className="mb-1 flex items-center gap-2 text-[10px] text-gray-400">
<span className="rounded bg-gray-200 px-1.5 py-0.5 font-medium text-gray-500">
@@ -199,7 +226,6 @@ export function Chat() {
</div>
)}
{/* Queued message indicator */}
{pendingMessage && (
<div className="flex justify-end">
<div className="max-w-[80%]">
@@ -214,20 +240,17 @@ export function Chat() {
)}
</div>
{/* Error */}
{error && (
<div className="shrink-0 border-t border-red-100 bg-red-50 px-4 py-2 text-sm text-red-600">
{error.message}
</div>
)}
{/* Input */}
<form
onSubmit={(e) => {
e.preventDefault();
if (!input.trim()) return;
if (status === "streaming") {
// Buffer the message — it will be sent when the current turn completes
setPendingMessage({ text: input, model });
} else {
trackedSendMessage({ text: input }, { metadata: { model } });
+8
View File
@@ -0,0 +1,8 @@
export const MODEL_OPTIONS = [
"gpt-4o-mini",
"gpt-4o",
"claude-sonnet-4-6",
"claude-opus-4-6",
];
export const DEFAULT_MODEL = "gpt-4o-mini";
+15
View File
@@ -0,0 +1,15 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../../lib/generated/prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };
function createClient() {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+20 -3
View File
@@ -5,6 +5,13 @@ import { openai } from "@ai-sdk/openai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
import os from "node:os";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../../lib/generated/prisma/client";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter });
import { DEFAULT_MODEL } from "@/lib/models";
const MODELS: Record<string, () => LanguageModel> = {
"gpt-4o-mini": () => openai("gpt-4o-mini"),
@@ -13,9 +20,6 @@ const MODELS: Record<string, () => LanguageModel> = {
"claude-opus-4-6": () => anthropic("claude-opus-4-6"),
};
export const MODEL_OPTIONS = Object.keys(MODELS);
export const DEFAULT_MODEL = "gpt-4o-mini";
function getModel(modelId?: string): LanguageModel {
const factory = MODELS[modelId ?? DEFAULT_MODEL];
if (!factory) return MODELS[DEFAULT_MODEL]!();
@@ -83,6 +87,19 @@ declare const Deno: unknown;
export const aiChat = chat.task({
id: "ai-chat",
warmTimeoutInSeconds: 10,
onChatStart: async ({ chatId }) => {
await prisma.chat.upsert({
where: { id: chatId },
create: { id: chatId, title: "New chat" },
update: {},
});
},
onTurnComplete: async ({ chatId, uiMessages }) => {
await prisma.chat.update({
where: { id: chatId },
data: { messages: uiMessages as any },
});
},
run: async ({ messages, clientData, stopSignal }) => {
const { model: modelId } = z
.object({ model: z.string().optional() })
+8
View File
@@ -1,7 +1,15 @@
import { defineConfig } from "@trigger.dev/sdk";
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";
export default defineConfig({
project: process.env.TRIGGER_PROJECT_REF!,
dirs: ["./src/trigger"],
maxDuration: 300,
build: {
extensions: [
prismaExtension({
mode: "modern",
}),
],
},
});