Skip to content

Commit 5133699

Browse files
committed
feat: add chat.local for per-run typed data with Proxy access and dirty tracking
1 parent 9bcd7a0 commit 5133699

4 files changed

Lines changed: 242 additions & 5 deletions

File tree

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,165 @@ function setWarmTimeoutInSeconds(seconds: number): void {
10621062
metadata.set(WARM_TIMEOUT_METADATA_KEY, seconds);
10631063
}
10641064

1065+
// ---------------------------------------------------------------------------
1066+
// chat.local — per-run typed data with Proxy access
1067+
// ---------------------------------------------------------------------------
1068+
1069+
/** @internal Symbol for storing the locals key on the proxy target. */
1070+
const CHAT_LOCAL_KEY: unique symbol = Symbol("chatLocalKey");
1071+
/** @internal Symbol for storing the dirty-tracking locals key. */
1072+
const CHAT_LOCAL_DIRTY_KEY: unique symbol = Symbol("chatLocalDirtyKey");
1073+
/** @internal Counter for generating unique locals IDs. */
1074+
let chatLocalCounter = 0;
1075+
1076+
/**
1077+
* A Proxy-backed, run-scoped data object that appears as `T` to users.
1078+
* Includes helper methods for initialization, dirty tracking, and serialization.
1079+
* Internal metadata is stored behind Symbols and invisible to
1080+
* `Object.keys()`, `JSON.stringify()`, and spread.
1081+
*/
1082+
export type ChatLocal<T extends Record<string, unknown>> = T & {
1083+
/** Initialize the local with a value. Call in `onChatStart` or `run()`. */
1084+
init(value: T): void;
1085+
/** Returns `true` if any property was set since the last check. Resets the dirty flag. */
1086+
hasChanged(): boolean;
1087+
/** Returns a plain object copy of the current value. Useful for persistence. */
1088+
get(): T;
1089+
readonly [CHAT_LOCAL_KEY]: ReturnType<typeof locals.create<T>>;
1090+
readonly [CHAT_LOCAL_DIRTY_KEY]: ReturnType<typeof locals.create<boolean>>;
1091+
};
1092+
1093+
/**
1094+
* Creates a per-run typed data object accessible from anywhere during task execution.
1095+
*
1096+
* Declare at module level, then initialize inside a lifecycle hook (e.g. `onChatStart`)
1097+
* using `chat.initLocal()`. Properties are accessible directly via the Proxy.
1098+
*
1099+
* Multiple locals can coexist — each gets its own isolated run-scoped storage.
1100+
*
1101+
* @example
1102+
* ```ts
1103+
* import { chat } from "@trigger.dev/sdk/ai";
1104+
*
1105+
* const userPrefs = chat.local<{ theme: string; language: string }>();
1106+
* const gameState = chat.local<{ score: number; streak: number }>();
1107+
*
1108+
* export const myChat = chat.task({
1109+
* id: "my-chat",
1110+
* onChatStart: async ({ clientData }) => {
1111+
* const prefs = await db.prefs.findUnique({ where: { userId: clientData.userId } });
1112+
* userPrefs.init(prefs ?? { theme: "dark", language: "en" });
1113+
* gameState.init({ score: 0, streak: 0 });
1114+
* },
1115+
* onTurnComplete: async ({ chatId }) => {
1116+
* if (gameState.hasChanged()) {
1117+
* await db.save({ where: { chatId }, data: gameState.get() });
1118+
* }
1119+
* },
1120+
* run: async ({ messages }) => {
1121+
* gameState.score++;
1122+
* return streamText({
1123+
* system: `User prefers ${userPrefs.theme} theme. Score: ${gameState.score}`,
1124+
* messages,
1125+
* });
1126+
* },
1127+
* });
1128+
* ```
1129+
*/
1130+
function chatLocal<T extends Record<string, unknown>>(): ChatLocal<T> {
1131+
const localKey = locals.create<T>(`chat.local.${chatLocalCounter++}`);
1132+
const dirtyKey = locals.create<boolean>(`chat.local.${chatLocalCounter++}.dirty`);
1133+
1134+
const target = {} as any;
1135+
target[CHAT_LOCAL_KEY] = localKey;
1136+
target[CHAT_LOCAL_DIRTY_KEY] = dirtyKey;
1137+
1138+
return new Proxy(target, {
1139+
get(_target, prop, _receiver) {
1140+
// Internal Symbol properties
1141+
if (prop === CHAT_LOCAL_KEY) return _target[CHAT_LOCAL_KEY];
1142+
if (prop === CHAT_LOCAL_DIRTY_KEY) return _target[CHAT_LOCAL_DIRTY_KEY];
1143+
1144+
// Instance methods
1145+
if (prop === "init") {
1146+
return (value: T) => {
1147+
locals.set(localKey, value);
1148+
locals.set(dirtyKey, false);
1149+
};
1150+
}
1151+
if (prop === "hasChanged") {
1152+
return () => {
1153+
const dirty = locals.get(dirtyKey) ?? false;
1154+
locals.set(dirtyKey, false);
1155+
return dirty;
1156+
};
1157+
}
1158+
if (prop === "get") {
1159+
return () => {
1160+
const current = locals.get(localKey);
1161+
if (current === undefined) {
1162+
throw new Error(
1163+
"local.get() called before initialization. Call local.init() first."
1164+
);
1165+
}
1166+
return { ...current };
1167+
};
1168+
}
1169+
// toJSON for serialization (JSON.stringify(local))
1170+
if (prop === "toJSON") {
1171+
return () => {
1172+
const current = locals.get(localKey);
1173+
return current ? { ...current } : undefined;
1174+
};
1175+
}
1176+
1177+
const current = locals.get(localKey);
1178+
if (current === undefined) return undefined;
1179+
return (current as any)[prop];
1180+
},
1181+
1182+
set(_target, prop, value) {
1183+
// Don't allow setting internal Symbols
1184+
if (typeof prop === "symbol") return false;
1185+
1186+
const current = locals.get(localKey);
1187+
if (current === undefined) {
1188+
throw new Error(
1189+
"chat.local can only be modified after initialization. " +
1190+
"Call local.init() in onChatStart or run() first."
1191+
);
1192+
}
1193+
locals.set(localKey, { ...current, [prop]: value });
1194+
locals.set(dirtyKey, true);
1195+
return true;
1196+
},
1197+
1198+
has(_target, prop) {
1199+
if (typeof prop === "symbol") return prop in _target;
1200+
const current = locals.get(localKey);
1201+
return current !== undefined && prop in current;
1202+
},
1203+
1204+
ownKeys() {
1205+
const current = locals.get(localKey);
1206+
return current ? Reflect.ownKeys(current) : [];
1207+
},
1208+
1209+
getOwnPropertyDescriptor(_target, prop) {
1210+
if (typeof prop === "symbol") return undefined;
1211+
const current = locals.get(localKey);
1212+
if (current === undefined || !(prop in current)) return undefined;
1213+
return {
1214+
configurable: true,
1215+
enumerable: true,
1216+
writable: true,
1217+
value: (current as any)[prop],
1218+
};
1219+
},
1220+
}) as ChatLocal<T>;
1221+
}
1222+
1223+
10651224
/**
10661225
* Extracts the client data (metadata) type from a chat task.
10671226
* Use this to type the `metadata` option on the transport.
@@ -1088,6 +1247,8 @@ export const chat = {
10881247
task: chatTask,
10891248
/** Pipe a stream to the chat transport. See {@link pipeChat}. */
10901249
pipe: pipeChat,
1250+
/** Create a per-run typed local. See {@link chatLocal}. */
1251+
local: chatLocal,
10911252
/** Create a public access token for a chat task. See {@link createChatAccessToken}. */
10921253
createAccessToken: createChatAccessToken,
10931254
/** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- AlterTable
2+
ALTER TABLE "Chat" ADD COLUMN "userId" TEXT;
3+
4+
-- CreateTable
5+
CREATE TABLE "User" (
6+
"id" TEXT NOT NULL,
7+
"name" TEXT NOT NULL,
8+
"plan" TEXT NOT NULL DEFAULT 'free',
9+
"preferredModel" TEXT,
10+
"messageCount" INTEGER NOT NULL DEFAULT 0,
11+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12+
"updatedAt" TIMESTAMP(3) NOT NULL,
13+
14+
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
15+
);
16+
17+
-- AddForeignKey
18+
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

references/ai-chat/prisma/schema.prisma

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,23 @@ datasource db {
77
provider = "postgresql"
88
}
99

10+
model User {
11+
id String @id
12+
name String
13+
plan String @default("free") // "free" | "pro"
14+
preferredModel String?
15+
messageCount Int @default(0)
16+
createdAt DateTime @default(now())
17+
updatedAt DateTime @updatedAt
18+
chats Chat[]
19+
}
20+
1021
model Chat {
1122
id String @id
1223
title String
1324
messages Json @default("[]")
25+
userId String?
26+
user User? @relation(fields: [userId], references: [id])
1427
createdAt DateTime @default(now())
1528
updatedAt DateTime @updatedAt
1629
}

references/ai-chat/src/trigger/chat.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,38 @@ const inspectEnvironment = tool({
8484
declare const Bun: unknown;
8585
declare const Deno: unknown;
8686

87+
// Per-run user context — loaded from DB in onChatStart, accessible everywhere
88+
const userContext = chat.local<{
89+
userId: string;
90+
name: string;
91+
plan: "free" | "pro";
92+
preferredModel: string | null;
93+
messageCount: number;
94+
}>();
95+
8796
export const aiChat = chat.task({
8897
id: "ai-chat",
8998
clientDataSchema: z.object({ model: z.string().optional(), userId: z.string() }),
9099
warmTimeoutInSeconds: 60,
91100
chatAccessTokenTTL: "2h",
92-
onChatStart: async ({ chatId, runId, chatAccessToken }) => {
101+
onChatStart: async ({ chatId, runId, chatAccessToken, clientData }) => {
102+
// Load user context from DB — available for the entire run
103+
const user = await prisma.user.upsert({
104+
where: { id: clientData.userId },
105+
create: { id: clientData.userId, name: "User" },
106+
update: {},
107+
});
108+
userContext.init({
109+
userId: user.id,
110+
name: user.name,
111+
plan: user.plan as "free" | "pro",
112+
preferredModel: user.preferredModel,
113+
messageCount: user.messageCount,
114+
});
115+
93116
await prisma.chat.upsert({
94117
where: { id: chatId },
95-
create: { id: chatId, title: "New chat" },
118+
create: { id: chatId, title: "New chat", userId: user.id },
96119
update: {},
97120
});
98121
await prisma.chatSession.upsert({
@@ -113,7 +136,7 @@ export const aiChat = chat.task({
113136
update: { runId, publicAccessToken: chatAccessToken },
114137
});
115138
},
116-
onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => {
139+
onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId, clientData }) => {
117140
// Persist final messages + assistant response + stream position
118141
await prisma.chat.update({
119142
where: { id: chatId },
@@ -124,11 +147,33 @@ export const aiChat = chat.task({
124147
create: { id: chatId, runId, publicAccessToken: chatAccessToken, lastEventId },
125148
update: { runId, publicAccessToken: chatAccessToken, lastEventId },
126149
});
150+
151+
// Persist user context changes (message count, preferred model) if anything changed
152+
if (userContext.hasChanged()) {
153+
await prisma.user.update({
154+
where: { id: userContext.userId },
155+
data: {
156+
messageCount: userContext.messageCount,
157+
preferredModel: userContext.preferredModel,
158+
},
159+
});
160+
}
127161
},
128162
run: async ({ messages, clientData, stopSignal }) => {
163+
// Track usage
164+
userContext.messageCount++;
165+
166+
// Remember their model choice
167+
if (clientData?.model) {
168+
userContext.preferredModel = clientData.model;
169+
}
170+
171+
// Use preferred model if none specified
172+
const modelId = clientData?.model ?? userContext.preferredModel ?? undefined;
173+
129174
return streamText({
130-
model: getModel(clientData?.model),
131-
system: "You are a helpful assistant. Be concise and friendly.",
175+
model: getModel(modelId),
176+
system: `You are a helpful assistant for ${userContext.name} (${userContext.plan} plan). Be concise and friendly.`,
132177
messages,
133178
tools: { inspectEnvironment },
134179
stopWhen: stepCountIs(10),

0 commit comments

Comments
 (0)