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 */
14import * as Channel from "../Channel.ts"
15import * as Chunk from "../Chunk.ts"
16import * as Context from "../Context.ts"
17import * as Duration from "../Duration.ts"
18import * as Effect from "../Effect.ts"
19import * as Layer from "../Layer.ts"
20import * as Option from "../Option.ts"
21import type { PersistenceError } from "../persistence/Persistence.ts"
22import { BackingPersistence } from "../persistence/Persistence.ts"
23import * as Predicate from "../Predicate.ts"
24import * as Ref from "../Ref.ts"
25import * as Schema from "../Schema.ts"
26import * as Semaphore from "../Semaphore.ts"
27import * as Stream from "../Stream.ts"
28import type { NoExcessProperties } from "../Types.ts"
29import * as AiError from "./AiError.ts"
30import * as IdGenerator from "./IdGenerator.ts"
31import * as LanguageModel from "./LanguageModel.ts"
32import * as Prompt from "./Prompt.ts"
33import type * as Response from "./Response.ts"
34import 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 */
76export 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 */
85export 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 */
94export 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 */
105export 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
412const decodeHistory = Schema.decodeUnknownEffect(Prompt.Prompt)
413const encodeHistory = Schema.encodeUnknownEffect(Prompt.Prompt)
414const decodeHistoryJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Prompt.Prompt))
415const encodeHistoryJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Prompt.Prompt))
416
417// =============================================================================
418// Constructors
419// =============================================================================
420
421const 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 */
547export 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 */
610export 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 */
664export 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 */
699export 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 */
721export 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
741export 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 */
751export 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 */
795export 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 */
826export 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 */
989export const layerPersisted = (options: {
990 readonly storeId: string
991}): Layer.Layer<Persistence, never, BackingPersistence> => Layer.effect(Persistence)(makePersisted(options))