| 1 | /** |
| 2 | * Stateful conversation sessions on top of a language model. |
| 3 | * |
| 4 | * A `Chat` keeps `Prompt` history in a `Ref` and reuses it for text generation, |
| 5 | * streaming, and structured output. Each generation call combines the current |
| 6 | * history with the caller's new prompt, invokes the active language model, and |
| 7 | * appends the response parts back into history. Constructors create fresh |
| 8 | * sessions, seed sessions from prompts, restore exported history, or connect a |
| 9 | * chat to persistence. |
| 10 | * |
| 11 | * @stability unstable |
| 12 | * @since 4.0.0 |
| 13 | */ |
| 14 | import * as Channel from "../Channel.ts" |
| 15 | import * as Chunk from "../Chunk.ts" |
| 16 | import * as Context from "../Context.ts" |
| 17 | import * as Duration from "../Duration.ts" |
| 18 | import * as Effect from "../Effect.ts" |
| 19 | import * as Layer from "../Layer.ts" |
| 20 | import * as Option from "../Option.ts" |
| 21 | import type { PersistenceError } from "../persistence/Persistence.ts" |
| 22 | import { BackingPersistence } from "../persistence/Persistence.ts" |
| 23 | import * as Predicate from "../Predicate.ts" |
| 24 | import * as Ref from "../Ref.ts" |
| 25 | import * as Schema from "../Schema.ts" |
| 26 | import * as Semaphore from "../Semaphore.ts" |
| 27 | import * as Stream from "../Stream.ts" |
| 28 | import type { NoExcessProperties } from "../Types.ts" |
| 29 | import * as AiError from "./AiError.ts" |
| 30 | import * as IdGenerator from "./IdGenerator.ts" |
| 31 | import * as LanguageModel from "./LanguageModel.ts" |
| 32 | import * as Prompt from "./Prompt.ts" |
| 33 | import type * as Response from "./Response.ts" |
| 34 | import type * as Tool from "./Tool.ts" |
| 35 | |
| 36 | /** |
| 37 | * Service key for stateful AI conversations. |
| 38 | * |
| 39 | * **Example** (Accessing the Chat service) |
| 40 | * |
| 41 | * ```ts import.meta.vitest |
| 42 | * import { Effect, Layer, Stream } from "effect" |
| 43 | * import { Chat, LanguageModel } from "effect/ai" |
| 44 | * |
| 45 | * const FakeLanguageModel = Layer.effect( |
| 46 | * LanguageModel.LanguageModel, |
| 47 | * LanguageModel.make({ |
| 48 | * generateText: () => |
| 49 | * Effect.succeed([{ |
| 50 | * type: "text", |
| 51 | * text: "Quantum computers use quantum states to process information." |
| 52 | * }]), |
| 53 | * streamText: () => Stream.empty |
| 54 | * }) |
| 55 | * ) |
| 56 | * |
| 57 | * const ChatLayer = Layer.effect(Chat.Chat, Chat.empty) |
| 58 | * |
| 59 | * const program = Effect.gen(function*() { |
| 60 | * const chat = yield* Chat.Chat |
| 61 | * const response = yield* chat.generateText({ |
| 62 | * prompt: "Explain quantum computing in simple terms" |
| 63 | * }) |
| 64 | * return response.text |
| 65 | * }) |
| 66 | * |
| 67 | * await Effect.runPromise( |
| 68 | * program.pipe(Effect.provide(Layer.merge(ChatLayer, FakeLanguageModel))) |
| 69 | * ) // => "Quantum computers use quantum states to process information." |
| 70 | * ``` |
| 71 | * |
| 72 | * @stability unstable |
| 73 | * @category services |
| 74 | * @since 4.0.0 |
| 75 | */ |
| 76 | export const Chat: Context.Service<Chat, Chat> = Context.Service("effect/ai/Chat") |
| 77 | |
| 78 | /** |
| 79 | * Brand type for `Chat`. |
| 80 | * |
| 81 | * @stability unstable |
| 82 | * @category type IDs |
| 83 | * @since 4.0.0 |
| 84 | */ |
| 85 | export type TypeId = "~effect/ai/Chat" |
| 86 | |
| 87 | /** |
| 88 | * Brand for `Chat` implementations. |
| 89 | * |
| 90 | * @stability unstable |
| 91 | * @category type IDs |
| 92 | * @since 4.0.0 |
| 93 | */ |
| 94 | export const TypeId: TypeId = "~effect/ai/Chat" |
| 95 | |
| 96 | /** |
| 97 | * Chat session with history, export, and generation operations. |
| 98 | * |
| 99 | * @see {@link Persisted} for the persistence-backed extension |
| 100 | * |
| 101 | * @stability unstable |
| 102 | * @category models |
| 103 | * @since 4.0.0 |
| 104 | */ |
| 105 | export interface Chat { |
| 106 | readonly [TypeId]: TypeId |
| 107 | |
| 108 | /** |
| 109 | * Reference to the chat history. |
| 110 | * |
| 111 | * **Details** |
| 112 | * |
| 113 | * Provides direct access to the conversation history for advanced use cases |
| 114 | * like custom history manipulation or inspection. |
| 115 | * |
| 116 | * **Example** (Inspecting chat history) |
| 117 | * |
| 118 | * ```ts import.meta.vitest |
| 119 | * import { Effect, Ref } from "effect" |
| 120 | * import { Chat } from "effect/ai" |
| 121 | * |
| 122 | * const inspectHistory = Effect.gen(function*() { |
| 123 | * const chat = yield* Chat.fromPrompt("Hello") |
| 124 | * const currentHistory = yield* Ref.get(chat.history) |
| 125 | * return currentHistory.content.length |
| 126 | * }) |
| 127 | * |
| 128 | * await Effect.runPromise(inspectHistory) // => 1 |
| 129 | * ``` |
| 130 | */ |
| 131 | readonly history: Ref.Ref<Prompt.Prompt> |
| 132 | |
| 133 | /** |
| 134 | * Exports the chat history into a structured format. |
| 135 | * |
| 136 | * **Details** |
| 137 | * |
| 138 | * Returns the complete conversation history as a structured object |
| 139 | * that can be stored, transmitted, or processed by other systems. |
| 140 | * |
| 141 | * **Example** (Exporting chat history) |
| 142 | * |
| 143 | * ```ts import.meta.vitest |
| 144 | * import { Effect } from "effect" |
| 145 | * import { Chat } from "effect/ai" |
| 146 | * |
| 147 | * const saveChat = Effect.gen(function*() { |
| 148 | * const chat = yield* Chat.fromPrompt("Hello!") |
| 149 | * const exportedData = yield* chat.export |
| 150 | * return typeof exportedData |
| 151 | * }) |
| 152 | * |
| 153 | * await Effect.runPromise(saveChat) // => "object" |
| 154 | * ``` |
| 155 | */ |
| 156 | readonly export: Effect.Effect<unknown, AiError.AiError> |
| 157 | |
| 158 | /** |
| 159 | * Exports the chat history as a JSON string. |
| 160 | * |
| 161 | * **Details** |
| 162 | * |
| 163 | * Provides a convenient way to serialize the entire conversation |
| 164 | * for storage or transmission in JSON format. |
| 165 | * |
| 166 | * **Example** (Exporting chat history as JSON) |
| 167 | * |
| 168 | * ```ts import.meta.vitest |
| 169 | * import { Effect } from "effect" |
| 170 | * import { Chat } from "effect/ai" |
| 171 | * |
| 172 | * const backupChat = Effect.gen(function*() { |
| 173 | * const chat = yield* Chat.fromPrompt("Explain photosynthesis") |
| 174 | * const jsonBackup = yield* chat.exportJson |
| 175 | * return JSON.parse(jsonBackup).content.length |
| 176 | * }) |
| 177 | * |
| 178 | * await Effect.runPromise(backupChat) // => 1 |
| 179 | * ``` |
| 180 | */ |
| 181 | readonly exportJson: Effect.Effect<string, AiError.AiError> |
| 182 | |
| 183 | /** |
| 184 | * Generate text using a language model for the specified prompt. |
| 185 | * |
| 186 | * **Details** |
| 187 | * |
| 188 | * If a toolkit is specified, the language model will have access to tools |
| 189 | * for function calling and enhanced capabilities. Both input and output |
| 190 | * messages are automatically added to the chat history. |
| 191 | * |
| 192 | * **Example** (Generating chat responses) |
| 193 | * |
| 194 | * ```ts import.meta.vitest |
| 195 | * import { Effect, Layer, Stream } from "effect" |
| 196 | * import { Chat, LanguageModel } from "effect/ai" |
| 197 | * |
| 198 | * const FakeLanguageModel = Layer.effect( |
| 199 | * LanguageModel.LanguageModel, |
| 200 | * LanguageModel.make({ |
| 201 | * generateText: (options) => |
| 202 | * Effect.succeed([{ |
| 203 | * type: "text", |
| 204 | * text: options.prompt.content.length === 1 |
| 205 | * ? "The capital of France is Paris." |
| 206 | * : "Paris has about 2.1 million residents." |
| 207 | * }]), |
| 208 | * streamText: () => Stream.empty |
| 209 | * }) |
| 210 | * ) |
| 211 | * |
| 212 | * const chatWithAI = Effect.gen(function*() { |
| 213 | * const chat = yield* Chat.empty |
| 214 | * |
| 215 | * const response1 = yield* chat.generateText({ |
| 216 | * prompt: "What is the capital of France?" |
| 217 | * }) |
| 218 | * const response2 = yield* chat.generateText({ |
| 219 | * prompt: "What's the population of that city?" |
| 220 | * }) |
| 221 | * return [response1.text, response2.text] |
| 222 | * }) |
| 223 | * |
| 224 | * await Effect.runPromise(chatWithAI.pipe(Effect.provide(FakeLanguageModel))) // => ["The capital of France is Paris.", "Paris has about 2.1 million residents."] |
| 225 | * ``` |
| 226 | */ |
| 227 | readonly generateText: { |
| 228 | <Options extends NoExcessProperties<LanguageModel.GenerateTextOptions<{}>, Options>>( |
| 229 | options: Options & { readonly toolkit?: undefined } & LanguageModel.GenerateTextOptions<{}> |
| 230 | ): Effect.Effect< |
| 231 | LanguageModel.GenerateTextResponse<{}>, |
| 232 | LanguageModel.ExtractError<Options>, |
| 233 | LanguageModel.LanguageModel | LanguageModel.ExtractServices<Options> |
| 234 | > |
| 235 | < |
| 236 | Tools extends Record<string, Tool.Any>, |
| 237 | Options extends NoExcessProperties< |
| 238 | LanguageModel.GenerateTextOptions<Tools> & { readonly toolkit: LanguageModel.ToolkitInput<Tools> }, |
| 239 | Options |
| 240 | > |
| 241 | >( |
| 242 | options: Options & LanguageModel.GenerateTextOptions<Tools> & { |
| 243 | readonly toolkit: LanguageModel.ToolkitInput<Tools> |
| 244 | } |
| 245 | ): Effect.Effect< |
| 246 | LanguageModel.GenerateTextResponse<Tools, LanguageModel.ExtractToolParametersMode<Options>>, |
| 247 | LanguageModel.ExtractError<Options>, |
| 248 | LanguageModel.LanguageModel | LanguageModel.ExtractServices<Options> |
| 249 | > |
| 250 | < |
| 251 | Options extends { |
| 252 | readonly toolkit: LanguageModel.ToolkitOption<any> |
| 253 | } & NoExcessProperties<LanguageModel.GenerateTextOptions<any>, Options> |
| 254 | >( |
| 255 | options: Options & LanguageModel.GenerateTextOptions<LanguageModel.ExtractTools<Options>> & { |
| 256 | readonly toolkit: Options["toolkit"] |
| 257 | } |
| 258 | ): Effect.Effect< |
| 259 | LanguageModel.GenerateTextResponse< |
| 260 | LanguageModel.ExtractTools<Options>, |
| 261 | LanguageModel.ExtractToolParametersMode<Options> |
| 262 | >, |
| 263 | LanguageModel.ExtractError<Options>, |
| 264 | LanguageModel.LanguageModel | LanguageModel.ExtractServices<Options> |
| 265 | > |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Generate text using a language model with streaming output. |
| 270 | * |
| 271 | * **Details** |
| 272 | * |
| 273 | * Returns a stream of response parts that are emitted as soon as they're |
| 274 | * available from the model. Supports tool calling and maintains chat history. |
| 275 | * |
| 276 | * **Example** (Streaming chat responses) |
| 277 | * |
| 278 | * ```ts import.meta.vitest |
| 279 | * import { Effect, Layer, Stream } from "effect" |
| 280 | * import { Chat, LanguageModel } from "effect/ai" |
| 281 | * |
| 282 | * const FakeLanguageModel = Layer.effect( |
| 283 | * LanguageModel.LanguageModel, |
| 284 | * LanguageModel.make({ |
| 285 | * generateText: () => Effect.succeed([]), |
| 286 | * streamText: () => |
| 287 | * Stream.make( |
| 288 | * { type: "text-delta", id: "story", delta: "A small probe reached orbit." }, |
| 289 | * { type: "text-delta", id: "story", delta: " It sent back a picture of Earth." } |
| 290 | * ) |
| 291 | * }) |
| 292 | * ) |
| 293 | * |
| 294 | * const streamingChat = Effect.gen(function*() { |
| 295 | * const chat = yield* Chat.empty |
| 296 | * const story = yield* chat.streamText({ |
| 297 | * prompt: "Write a short story about space exploration" |
| 298 | * }).pipe( |
| 299 | * Stream.runFold(() => "", (text, part) => |
| 300 | * part.type === "text-delta" ? text + part.delta : text) |
| 301 | * ) |
| 302 | * return story |
| 303 | * }) |
| 304 | * |
| 305 | * const story = await Effect.runPromise(streamingChat.pipe(Effect.provide(FakeLanguageModel))) |
| 306 | * story // => "A small probe reached orbit. It sent back a picture of Earth." |
| 307 | * ``` |
| 308 | */ |
| 309 | readonly streamText: { |
| 310 | <Options extends NoExcessProperties<LanguageModel.GenerateTextOptions<{}>, Options>>( |
| 311 | options: Options & { readonly toolkit?: undefined } & LanguageModel.GenerateTextOptions<{}> |
| 312 | ): Stream.Stream< |
| 313 | Response.StreamPart<{}>, |
| 314 | LanguageModel.ExtractError<Options>, |
| 315 | LanguageModel.LanguageModel | LanguageModel.ExtractServices<Options> |
| 316 | > |
| 317 | < |
| 318 | Tools extends Record<string, Tool.Any>, |
| 319 | Options extends NoExcessProperties< |
| 320 | LanguageModel.GenerateTextOptions<Tools> & { readonly toolkit: LanguageModel.ToolkitInput<Tools> }, |
| 321 | Options |
| 322 | > |
| 323 | >( |
| 324 | options: Options & LanguageModel.GenerateTextOptions<Tools> & { |
| 325 | readonly toolkit: LanguageModel.ToolkitInput<Tools> |
| 326 | } |
| 327 | ): Stream.Stream< |
| 328 | Response.StreamPart<Tools, LanguageModel.ExtractToolParametersMode<Options>>, |
| 329 | LanguageModel.ExtractError<Options>, |
| 330 | LanguageModel.LanguageModel | LanguageModel.ExtractServices<Options> |
| 331 | > |
| 332 | < |
| 333 | Options extends { |
| 334 | readonly toolkit: LanguageModel.ToolkitOption<any> |
| 335 | } & NoExcessProperties<LanguageModel.GenerateTextOptions<any>, Options> |
| 336 | >( |
| 337 | options: Options & LanguageModel.GenerateTextOptions<LanguageModel.ExtractTools<Options>> & { |
| 338 | readonly toolkit: Options["toolkit"] |
| 339 | } |
| 340 | ): Stream.Stream< |
| 341 | Response.StreamPart< |
| 342 | LanguageModel.ExtractTools<Options>, |
| 343 | LanguageModel.ExtractToolParametersMode<Options> |
| 344 | >, |
| 345 | LanguageModel.ExtractError<Options>, |
| 346 | LanguageModel.LanguageModel | LanguageModel.ExtractServices<Options> |
| 347 | > |
| 348 | } |
| 349 | |
| 350 | /** |
| 351 | * Generate a structured object using a language model and schema. |
| 352 | * |
| 353 | * **Details** |
| 354 | * |
| 355 | * Forces the model to return data that conforms to the specified schema, |
| 356 | * enabling structured data extraction and type-safe responses. The |
| 357 | * conversation history is maintained across calls. |
| 358 | * |
| 359 | * **Example** (Generating structured objects) |
| 360 | * |
| 361 | * ```ts import.meta.vitest |
| 362 | * import { Effect, Layer, Schema, Stream } from "effect" |
| 363 | * import { Chat, LanguageModel } from "effect/ai" |
| 364 | * |
| 365 | * const ContactSchema = Schema.Struct({ |
| 366 | * name: Schema.String, |
| 367 | * email: Schema.String, |
| 368 | * phone: Schema.optional(Schema.String) |
| 369 | * }) |
| 370 | * |
| 371 | * const FakeLanguageModel = Layer.effect( |
| 372 | * LanguageModel.LanguageModel, |
| 373 | * LanguageModel.make({ |
| 374 | * generateText: () => |
| 375 | * Effect.succeed([{ |
| 376 | * type: "text", |
| 377 | * text: '{"name":"John Doe","email":"john@example.com","phone":"555-1234"}' |
| 378 | * }]), |
| 379 | * streamText: () => Stream.empty |
| 380 | * }) |
| 381 | * ) |
| 382 | * |
| 383 | * const extractContact = Effect.gen(function*() { |
| 384 | * const chat = yield* Chat.empty |
| 385 | * const contact = yield* chat.generateObject({ |
| 386 | * prompt: "Extract contact info: John Doe, john@example.com, 555-1234", |
| 387 | * schema: ContactSchema |
| 388 | * }) |
| 389 | * return [contact.value.name, contact.value.email, contact.value.phone] |
| 390 | * }) |
| 391 | * |
| 392 | * await Effect.runPromise(extractContact.pipe(Effect.provide(FakeLanguageModel))) // => ["John Doe", "john@example.com", "555-1234"] |
| 393 | * ``` |
| 394 | */ |
| 395 | readonly generateObject: < |
| 396 | ObjectEncoded extends Record<string, any>, |
| 397 | ObjectSchema extends Schema.Encoder<ObjectEncoded, unknown>, |
| 398 | Options extends NoExcessProperties<LanguageModel.GenerateObjectOptions<any, ObjectSchema>, Options> |
| 399 | >( |
| 400 | options: Options & LanguageModel.GenerateObjectOptions<LanguageModel.ExtractTools<Options>, ObjectSchema> |
| 401 | ) => Effect.Effect< |
| 402 | LanguageModel.GenerateObjectResponse< |
| 403 | LanguageModel.ExtractTools<Options>, |
| 404 | ObjectSchema["Type"], |
| 405 | LanguageModel.ExtractToolParametersMode<Options> |
| 406 | >, |
| 407 | LanguageModel.ExtractError<Options>, |
| 408 | LanguageModel.ExtractServices<Options> | ObjectSchema["DecodingServices"] | LanguageModel.LanguageModel |
| 409 | > |
| 410 | } |
| 411 | |
| 412 | const decodeHistory = Schema.decodeUnknownEffect(Prompt.Prompt) |
| 413 | const encodeHistory = Schema.encodeUnknownEffect(Prompt.Prompt) |
| 414 | const decodeHistoryJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Prompt.Prompt)) |
| 415 | const encodeHistoryJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Prompt.Prompt)) |
| 416 | |
| 417 | // ============================================================================= |
| 418 | // Constructors |
| 419 | // ============================================================================= |
| 420 | |
| 421 | const makeUnsafe = (history: Ref.Ref<Prompt.Prompt>) => { |
| 422 | const semaphore = Semaphore.makeUnsafe(1) |
| 423 | |
| 424 | return Chat.of({ |
| 425 | [TypeId]: TypeId, |
| 426 | history, |
| 427 | export: Ref.get(history).pipe( |
| 428 | Effect.flatMap(encodeHistory), |
| 429 | Effect.catchTag("SchemaError", (error) => |
| 430 | Effect.fail(AiError.make({ |
| 431 | module: "Chat", |
| 432 | method: "export", |
| 433 | reason: AiError.InvalidOutputError.fromSchemaError(error) |
| 434 | }))), |
| 435 | Effect.withSpan("Chat.export") |
| 436 | ), |
| 437 | exportJson: Ref.get(history).pipe( |
| 438 | Effect.flatMap(encodeHistoryJson), |
| 439 | Effect.catchTag("SchemaError", (error) => |
| 440 | Effect.fail(AiError.make({ |
| 441 | module: "Chat", |
| 442 | method: "exportJson", |
| 443 | reason: AiError.InvalidOutputError.fromSchemaError(error) |
| 444 | }))), |
| 445 | Effect.withSpan("Chat.exportJson") |
| 446 | ), |
| 447 | generateText: Effect.fnUntraced( |
| 448 | function*(options) { |
| 449 | const newPrompt = Prompt.make(options.prompt) |
| 450 | const oldPrompt = yield* Ref.get(history) |
| 451 | const prompt = Prompt.concat(oldPrompt, newPrompt) |
| 452 | |
| 453 | const response = yield* LanguageModel.generateText({ ...options, prompt }) |
| 454 | |
| 455 | const newHistory = Prompt.concat(prompt, Prompt.fromResponseParts(response.content)) |
| 456 | yield* Ref.set(history, newHistory) |
| 457 | |
| 458 | return response |
| 459 | }, |
| 460 | semaphore.withPermits(1), |
| 461 | (effect) => Effect.withSpan(effect, "Chat.generateText", { captureStackTrace: false }) |
| 462 | ) as Chat["generateText"], |
| 463 | streamText: Effect.fnUntraced( |
| 464 | function*(options) { |
| 465 | let parts = Chunk.empty<Response.AnyPart>() |
| 466 | return Stream.fromChannel(Channel.acquireUseRelease( |
| 467 | semaphore.take(1).pipe( |
| 468 | Effect.flatMap(() => Ref.get(history)), |
| 469 | Effect.map((history) => Prompt.concat(history, Prompt.make(options.prompt))) |
| 470 | ), |
| 471 | (prompt) => |
| 472 | LanguageModel.streamText({ ...options, prompt }).pipe( |
| 473 | Stream.mapArray((chunk) => { |
| 474 | parts = Chunk.appendAll(parts, Chunk.fromArrayUnsafe(chunk)) |
| 475 | return chunk |
| 476 | }), |
| 477 | Stream.toChannel |
| 478 | ), |
| 479 | (prompt) => |
| 480 | Effect.andThen( |
| 481 | Ref.set( |
| 482 | history, |
| 483 | Prompt.concat(prompt, Prompt.fromResponseParts(Array.from(parts))) |
| 484 | ), |
| 485 | semaphore.release(1) |
| 486 | ) |
| 487 | )).pipe( |
| 488 | Stream.withSpan("Chat.streamText", { |
| 489 | captureStackTrace: false |
| 490 | }) |
| 491 | ) |
| 492 | }, |
| 493 | Stream.unwrap |
| 494 | ) as Chat["streamText"], |
| 495 | generateObject: Effect.fnUntraced( |
| 496 | function*(options) { |
| 497 | const newPrompt = Prompt.make(options.prompt) |
| 498 | const oldPrompt = yield* Ref.get(history) |
| 499 | const prompt = Prompt.concat(oldPrompt, newPrompt) |
| 500 | |
| 501 | const response = yield* LanguageModel.generateObject({ ...options, prompt }) |
| 502 | |
| 503 | const newHistory = Prompt.concat(prompt, Prompt.fromResponseParts(response.content)) |
| 504 | yield* Ref.set(history, newHistory) |
| 505 | |
| 506 | return response |
| 507 | }, |
| 508 | semaphore.withPermits(1), |
| 509 | (effect, options) => |
| 510 | Effect.withSpan(effect, "Chat.generateObject", { |
| 511 | attributes: { |
| 512 | objectName: LanguageModel.getObjectName(options.objectName, options.schema) |
| 513 | }, |
| 514 | captureStackTrace: false |
| 515 | }) |
| 516 | ) |
| 517 | }) |
| 518 | } |
| 519 | |
| 520 | /** |
| 521 | * Creates a new Chat service with empty conversation history. |
| 522 | * |
| 523 | * **When to use** |
| 524 | * |
| 525 | * Use when you need to start a fresh chat session without initial context or |
| 526 | * system prompts. |
| 527 | * |
| 528 | * **Example** (Creating an empty chat) |
| 529 | * |
| 530 | * ```ts import.meta.vitest |
| 531 | * import { Effect } from "effect" |
| 532 | * import { Chat } from "effect/ai" |
| 533 | * |
| 534 | * const freshChat = Effect.gen(function*() { |
| 535 | * const chat = yield* Chat.empty |
| 536 | * const history = yield* chat.export |
| 537 | * return (history as { content: ReadonlyArray<unknown> }).content.length |
| 538 | * }) |
| 539 | * |
| 540 | * await Effect.runPromise(freshChat) // => 0 |
| 541 | * ``` |
| 542 | * |
| 543 | * @stability unstable |
| 544 | * @category constructors |
| 545 | * @since 4.0.0 |
| 546 | */ |
| 547 | export const empty: Effect.Effect<Chat> = Effect.sync(() => makeUnsafe(Ref.makeUnsafe(Prompt.empty))) |
| 548 | |
| 549 | /** |
| 550 | * Creates a new Chat service from an initial prompt. |
| 551 | * |
| 552 | * **Details** |
| 553 | * |
| 554 | * This is the primary constructor for creating chat instances. It initializes |
| 555 | * a new conversation with the provided prompt as the starting context. |
| 556 | * |
| 557 | * **Example** (Creating a chat from a system prompt) |
| 558 | * |
| 559 | * ```ts import.meta.vitest |
| 560 | * import { Effect } from "effect" |
| 561 | * import { Chat } from "effect/ai" |
| 562 | * |
| 563 | * const chatWithSystemPrompt = Effect.gen(function*() { |
| 564 | * const chat = yield* Chat.fromPrompt([{ |
| 565 | * role: "system", |
| 566 | * content: "You are a helpful assistant specialized in mathematics." |
| 567 | * }]) |
| 568 | * |
| 569 | * const history = yield* chat.export |
| 570 | * return (history as { content: ReadonlyArray<unknown> }).content.length |
| 571 | * }) |
| 572 | * |
| 573 | * await Effect.runPromise(chatWithSystemPrompt) // => 1 |
| 574 | * ``` |
| 575 | * |
| 576 | * **Example** (Restoring chat history from a prompt) |
| 577 | * |
| 578 | * ```ts import.meta.vitest |
| 579 | * import { Effect } from "effect" |
| 580 | * import { Chat } from "effect/ai" |
| 581 | * |
| 582 | * // Initialize with conversation history |
| 583 | * const existingChat = Effect.gen(function*() { |
| 584 | * const chat = yield* Chat.fromPrompt([ |
| 585 | * { |
| 586 | * role: "user", |
| 587 | * content: [{ type: "text", text: "What's the weather like?" }] |
| 588 | * }, |
| 589 | * { |
| 590 | * role: "assistant", |
| 591 | * content: [{ type: "text", text: "I don't have access to weather data." }] |
| 592 | * }, |
| 593 | * { |
| 594 | * role: "user", |
| 595 | * content: [{ type: "text", text: "Can you help me with coding?" }] |
| 596 | * } |
| 597 | * ]) |
| 598 | * |
| 599 | * const history = yield* chat.export |
| 600 | * return (history as { content: ReadonlyArray<unknown> }).content.length |
| 601 | * }) |
| 602 | * |
| 603 | * await Effect.runPromise(existingChat) // => 3 |
| 604 | * ``` |
| 605 | * |
| 606 | * @stability unstable |
| 607 | * @category constructors |
| 608 | * @since 4.0.0 |
| 609 | */ |
| 610 | export const fromPrompt = (prompt: Prompt.RawInput) => |
| 611 | Effect.sync(() => makeUnsafe(Ref.makeUnsafe(Prompt.make(prompt)))) |
| 612 | |
| 613 | /** |
| 614 | * Creates a Chat service from previously exported chat data. |
| 615 | * |
| 616 | * **Details** |
| 617 | * |
| 618 | * Restores a chat session from structured data that was previously exported |
| 619 | * using the `export` method. Useful for persisting and restoring conversation |
| 620 | * state. |
| 621 | * |
| 622 | * **Example** (Restoring chat data) |
| 623 | * |
| 624 | * ```ts import.meta.vitest |
| 625 | * import { Effect, Ref } from "effect" |
| 626 | * import { Chat } from "effect/ai" |
| 627 | * |
| 628 | * const restoreChat = Effect.gen(function*() { |
| 629 | * const originalChat = yield* Chat.fromPrompt([ |
| 630 | * { |
| 631 | * role: "user", |
| 632 | * content: "Which library are we using?" |
| 633 | * }, |
| 634 | * { |
| 635 | * role: "assistant", |
| 636 | * content: "The project uses Effect." |
| 637 | * } |
| 638 | * ]) |
| 639 | * |
| 640 | * const exported = yield* originalChat.export |
| 641 | * const restoredChat = yield* Chat.fromExport(exported) |
| 642 | * const restoredHistory = yield* Ref.get(restoredChat.history) |
| 643 | * |
| 644 | * const restoredResponse = restoredHistory.content[1] |
| 645 | * if (restoredResponse?.role === "assistant") { |
| 646 | * const restoredText = restoredResponse.content[0] |
| 647 | * if (restoredText?.type === "text") { |
| 648 | * return { |
| 649 | * roles: restoredHistory.content.map((message) => message.role), |
| 650 | * text: restoredText.text |
| 651 | * } |
| 652 | * } |
| 653 | * } |
| 654 | * return undefined |
| 655 | * }) |
| 656 | * |
| 657 | * await Effect.runPromise(restoreChat) // => { roles: ["user", "assistant"], text: "The project uses Effect." } |
| 658 | * ``` |
| 659 | * |
| 660 | * @stability unstable |
| 661 | * @category constructors |
| 662 | * @since 4.0.0 |
| 663 | */ |
| 664 | export const fromExport = (data: unknown): Effect.Effect< |
| 665 | Chat, |
| 666 | Schema.SchemaError |
| 667 | > => Effect.flatMap(decodeHistory(data), fromPrompt) |
| 668 | |
| 669 | /** |
| 670 | * Creates a Chat service from previously exported JSON chat data. |
| 671 | * |
| 672 | * **Details** |
| 673 | * |
| 674 | * Restores a chat session from JSON string that was previously exported |
| 675 | * using the `exportJson` method. This is the most convenient way to |
| 676 | * persist and restore chat sessions to/from storage systems. |
| 677 | * |
| 678 | * **Example** (Restoring chat history from JSON) |
| 679 | * |
| 680 | * ```ts import.meta.vitest |
| 681 | * import { Effect, Ref } from "effect" |
| 682 | * import { Chat } from "effect/ai" |
| 683 | * |
| 684 | * const restoreFromJson = Effect.gen(function*() { |
| 685 | * const original = yield* Chat.fromPrompt("Hello") |
| 686 | * const jsonData = yield* original.exportJson |
| 687 | * const restoredChat = yield* Chat.fromJson(jsonData) |
| 688 | * const history = yield* Ref.get(restoredChat.history) |
| 689 | * return history.content.length |
| 690 | * }) |
| 691 | * |
| 692 | * await Effect.runPromise(restoreFromJson) // => 1 |
| 693 | * ``` |
| 694 | * |
| 695 | * @stability unstable |
| 696 | * @category constructors |
| 697 | * @since 4.0.0 |
| 698 | */ |
| 699 | export const fromJson = (data: string): Effect.Effect< |
| 700 | Chat, |
| 701 | Schema.SchemaError |
| 702 | > => Effect.flatMap(decodeHistoryJson(data), fromPrompt) |
| 703 | |
| 704 | // ============================================================================= |
| 705 | // Chat Persistence |
| 706 | // ============================================================================= |
| 707 | |
| 708 | /** |
| 709 | * Represents an error that occurs when attempting to retrieve a persisted `Chat` that |
| 710 | * does not exist in the backing persistence store. |
| 711 | * |
| 712 | * **When to use** |
| 713 | * |
| 714 | * Use to represent a missing persisted conversation when lookup by id cannot |
| 715 | * find stored history. |
| 716 | * |
| 717 | * @stability unstable |
| 718 | * @category errors |
| 719 | * @since 4.0.0 |
| 720 | */ |
| 721 | export class ChatNotFoundError extends Schema.Error<ChatNotFoundError>( |
| 722 | "effect/ai/Chat/ChatNotFoundError" |
| 723 | )({ |
| 724 | _tag: Schema.tag("ChatNotFoundError"), |
| 725 | chatId: Schema.String |
| 726 | }) {} |
| 727 | |
| 728 | /** |
| 729 | * Service tag for persistence-backed AI conversation storage. |
| 730 | * |
| 731 | * **When to use** |
| 732 | * |
| 733 | * Use to provide the storage operations needed by persisted conversation |
| 734 | * sessions. |
| 735 | * |
| 736 | * @stability unstable |
| 737 | * @category services |
| 738 | * @since 4.0.0 |
| 739 | */ |
| 740 | // @effect-diagnostics effect/leakingRequirements:off |
| 741 | export class Persistence extends Context.Service<Persistence, Persistence.Service>()( |
| 742 | "effect/ai/Chat/Persisted" |
| 743 | ) {} |
| 744 | |
| 745 | /** |
| 746 | * Namespace containing the service contract for chat persistence. |
| 747 | * |
| 748 | * @stability unstable |
| 749 | * @since 4.0.0 |
| 750 | */ |
| 751 | export declare namespace Persistence { |
| 752 | /** |
| 753 | * Represents the backing persistence for a persisted `Chat`. Allows for |
| 754 | * creating and retrieving chats that have been saved to a persistence store. |
| 755 | * |
| 756 | * @stability unstable |
| 757 | * @category models |
| 758 | * @since 4.0.0 |
| 759 | */ |
| 760 | export interface Service { |
| 761 | /** |
| 762 | * Attempts to retrieve the persisted chat from the backing persistence |
| 763 | * store with the specified chat identifer. If the chat does not exist in |
| 764 | * the persistence store, a `ChatNotFoundError` will be returned. |
| 765 | */ |
| 766 | readonly get: (chatId: string, options?: { |
| 767 | readonly timeToLive?: Duration.Input | undefined |
| 768 | }) => Effect.Effect<Persisted, ChatNotFoundError | PersistenceError> |
| 769 | |
| 770 | /** |
| 771 | * Attempts to retrieve the persisted chat from the backing persistence |
| 772 | * store with the specified chat identifer. If the chat does not exist in |
| 773 | * the persistence store, an empty chat will be created, saved, and |
| 774 | * returned. |
| 775 | */ |
| 776 | readonly getOrCreate: (chatId: string, options?: { |
| 777 | readonly timeToLive?: Duration.Input | undefined |
| 778 | }) => Effect.Effect<Persisted, AiError.AiError | PersistenceError> |
| 779 | } |
| 780 | } |
| 781 | |
| 782 | /** |
| 783 | * Represents a `Chat` that is backed by persistence. |
| 784 | * |
| 785 | * **Details** |
| 786 | * |
| 787 | * When calling a text generation method (e.g. `generateText`), the previous |
| 788 | * chat history as well as the relevent response parts will be saved to the |
| 789 | * backing persistence store. |
| 790 | * |
| 791 | * @stability unstable |
| 792 | * @category models |
| 793 | * @since 4.0.0 |
| 794 | */ |
| 795 | export interface Persisted extends Chat { |
| 796 | /** |
| 797 | * The identifier for the chat in the backing persistence store. |
| 798 | */ |
| 799 | readonly id: string |
| 800 | |
| 801 | /** |
| 802 | * Saves the current chat history into the backing persistence store. |
| 803 | */ |
| 804 | readonly save: Effect.Effect<void, AiError.AiError | PersistenceError> |
| 805 | } |
| 806 | |
| 807 | /** |
| 808 | * Creates a new chat persistence service. |
| 809 | * |
| 810 | * **When to use** |
| 811 | * |
| 812 | * Use when you need programmatic persisted chat creation and retrieval backed |
| 813 | * by the current `BackingPersistence`. |
| 814 | * |
| 815 | * **Details** |
| 816 | * |
| 817 | * The provided store identifier will be used to indicate which "store" the |
| 818 | * backing persistence should load chats from. |
| 819 | * |
| 820 | * @see {@link layerPersisted} for the `Layer`-based constructor |
| 821 | * |
| 822 | * @stability unstable |
| 823 | * @category constructors |
| 824 | * @since 4.0.0 |
| 825 | */ |
| 826 | export const makePersisted = Effect.fnUntraced(function*(options: { |
| 827 | readonly storeId: string |
| 828 | }) { |
| 829 | const persistence = yield* BackingPersistence |
| 830 | const store = yield* persistence.make(options.storeId) |
| 831 | |
| 832 | const toPersisted = Effect.fnUntraced( |
| 833 | function*(chatId: string, chat: Chat, ttl: Duration.Input | undefined) { |
| 834 | const idGenerator = yield* Effect.serviceOption(IdGenerator.IdGenerator).pipe( |
| 835 | Effect.map(Option.getOrElse(() => IdGenerator.defaultIdGenerator)) |
| 836 | ) |
| 837 | |
| 838 | const saveChat = Effect.fnUntraced( |
| 839 | function*(prevHistory: Prompt.Prompt) { |
| 840 | // Get the current chat history |
| 841 | const history = yield* Ref.get(chat.history) |
| 842 | // Get the most recent message stored in the previous chat history |
| 843 | const lastMessage = prevHistory.content[prevHistory.content.length - 1] |
| 844 | // Determine the correct message identifier to use: |
| 845 | let messageId: string | undefined = undefined |
| 846 | // If the most recent message in the chat history is an assistant message, |
| 847 | // use the message identifer stored in that message |
| 848 | if (Predicate.isNotUndefined(lastMessage) && lastMessage.role === "assistant") { |
| 849 | messageId = (lastMessage.options[Persistence.key] as any)?.messageId |
| 850 | } |
| 851 | // If the chat history is empty or a message identifier did not exist on |
| 852 | // the most recent message in the chat history, generate a new identifier |
| 853 | if (Predicate.isUndefined(messageId)) { |
| 854 | messageId = yield* idGenerator.generateId() |
| 855 | } |
| 856 | // Mutate the new messages to add the generated message identifier |
| 857 | for (let i = prevHistory.content.length; i < history.content.length; i++) { |
| 858 | const message = history.content[i] |
| 859 | ;(message.options as any)[Persistence.key] = { messageId } |
| 860 | } |
| 861 | // Save the mutated history back to the ref |
| 862 | yield* Ref.set(chat.history, history) |
| 863 | // Export the chat history |
| 864 | const exported = yield* Effect.orDie(chat.export) |
| 865 | const timeToLive = Predicate.isNotUndefined(ttl) |
| 866 | ? Option.getOrUndefined(Duration.fromInput(ttl)) |
| 867 | : undefined |
| 868 | // Save the chat to the backing store |
| 869 | yield* store.set(chatId, exported as object, timeToLive) |
| 870 | } |
| 871 | ) |
| 872 | |
| 873 | const persisted: Persisted = { |
| 874 | ...chat, |
| 875 | id: chatId, |
| 876 | save: Effect.flatMap(Ref.get(chat.history), saveChat), |
| 877 | generateText: Effect.fnUntraced(function*(options) { |
| 878 | const history = yield* Ref.get(chat.history) |
| 879 | return yield* chat.generateText(options).pipe( |
| 880 | Effect.ensuring(Effect.orDie(saveChat(history))) |
| 881 | ) |
| 882 | }) as Chat["generateText"], |
| 883 | generateObject: Effect.fnUntraced(function*(options) { |
| 884 | const history = yield* Ref.get(chat.history) |
| 885 | return yield* chat.generateObject(options).pipe( |
| 886 | Effect.ensuring(Effect.orDie(saveChat(history))) |
| 887 | ) |
| 888 | }), |
| 889 | streamText: Effect.fnUntraced(function*(options) { |
| 890 | const history = yield* Ref.get(chat.history) |
| 891 | const stream = chat.streamText(options).pipe( |
| 892 | Stream.ensuring(Effect.orDie(saveChat(history))) |
| 893 | ) |
| 894 | return stream |
| 895 | }, Stream.unwrap) as Chat["streamText"] |
| 896 | } |
| 897 | |
| 898 | return persisted |
| 899 | } |
| 900 | ) |
| 901 | |
| 902 | const createChat = Effect.fnUntraced( |
| 903 | function*(chatId: string, ttl: Duration.Input | undefined) { |
| 904 | // Create an empty chat |
| 905 | const chat = yield* empty |
| 906 | // Export the chat history |
| 907 | const history = yield* Effect.orDie(chat.export) |
| 908 | // Save the history for the newly created chat |
| 909 | const timeToLive = Predicate.isNotUndefined(ttl) |
| 910 | ? Option.getOrUndefined(Duration.fromInput(ttl)) |
| 911 | : undefined |
| 912 | yield* store.set(chatId, history as object, timeToLive) |
| 913 | // Convert the chat to a persisted chat |
| 914 | return yield* toPersisted(chatId, chat, ttl) |
| 915 | } |
| 916 | ) |
| 917 | |
| 918 | const getChat = Effect.fnUntraced( |
| 919 | function*(chatId: string, ttl: Duration.Input | undefined) { |
| 920 | // Create an empty chat |
| 921 | const chat = yield* empty |
| 922 | // Attempt to retrieve the previous history from the store |
| 923 | const previousHistory = yield* store.get(chatId) |
| 924 | // If the previous history was not found, raise an error |
| 925 | if (Predicate.isUndefined(previousHistory)) { |
| 926 | return yield* new ChatNotFoundError({ chatId }) |
| 927 | } |
| 928 | // Decode the encoded previous history |
| 929 | const history = yield* decodeHistory(previousHistory) |
| 930 | // Hydrate the chat history |
| 931 | yield* Ref.set(chat.history, history) |
| 932 | // Convert the chat to a persisted chat |
| 933 | return yield* toPersisted(chatId, chat, ttl) |
| 934 | }, |
| 935 | Effect.catchTag("SchemaError", Effect.die) |
| 936 | ) |
| 937 | |
| 938 | const get = Effect.fnUntraced( |
| 939 | function*(chatId: string, options?: { |
| 940 | readonly timeToLive?: Duration.Input | undefined |
| 941 | }) { |
| 942 | return yield* getChat(chatId, options?.timeToLive) |
| 943 | }, |
| 944 | (effect) => |
| 945 | Effect.withSpan(effect, "PersistedChat.get", { |
| 946 | captureStackTrace: false |
| 947 | }) |
| 948 | ) |
| 949 | |
| 950 | const getOrCreate = Effect.fnUntraced( |
| 951 | function*(chatId: string, options?: { |
| 952 | readonly timeToLive?: Duration.Input | undefined |
| 953 | }) { |
| 954 | return yield* getChat(chatId, options?.timeToLive).pipe( |
| 955 | Effect.catchTag("ChatNotFoundError", () => createChat(chatId, options?.timeToLive)) |
| 956 | ) |
| 957 | }, |
| 958 | (effect) => |
| 959 | Effect.withSpan(effect, "PersistedChat.getOrCreate", { |
| 960 | captureStackTrace: false |
| 961 | }) |
| 962 | ) |
| 963 | |
| 964 | return Persistence.of({ |
| 965 | get, |
| 966 | getOrCreate |
| 967 | }) |
| 968 | }) |
| 969 | |
| 970 | /** |
| 971 | * Creates a `Layer` for a new chat persistence service. |
| 972 | * |
| 973 | * **When to use** |
| 974 | * |
| 975 | * Use to provide `Chat.Persistence` from a configured `BackingPersistence` when |
| 976 | * your application needs persisted chat sessions backed by a named store. |
| 977 | * |
| 978 | * **Details** |
| 979 | * |
| 980 | * The provided store identifier will be used to indicate which "store" the |
| 981 | * backing persistence should load chats from. |
| 982 | * |
| 983 | * @see {@link makePersisted} for the effect constructor when building the service directly instead of providing it as a layer |
| 984 | * |
| 985 | * @stability unstable |
| 986 | * @category layers |
| 987 | * @since 4.0.0 |
| 988 | */ |
| 989 | export const layerPersisted = (options: { |
| 990 | readonly storeId: string |
| 991 | }): Layer.Layer<Persistence, never, BackingPersistence> => Layer.effect(Persistence)(makePersisted(options)) |