1import * as discoveryengine from "@distilled.cloud/gcp/discoveryengine_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import { parentOf, parseResourceName, sameJson } from "./internal.ts";
10
11export type ConversationMessage = {
12 userInput?: {
13 input?: string;
14 context?: {
15 contextDocuments?: string[];
16 activeDocument?: string;
17 };
18 };
19};
20
21export type CollectionsDataStoresConversationProps = {
22 /**
23 * Parent data store resource name. Immutable — changing it replaces
24 * the conversation.
25 */
26 dataStore: string;
27 /**
28 * Conversation state.
29 * @default "IN_PROGRESS"
30 */
31 state?: "STATE_UNSPECIFIED" | "IN_PROGRESS" | "COMPLETED" | (string & {});
32 /**
33 * End-user id.
34 */
35 userPseudoId?: string;
36 /**
37 * Conversation messages.
38 */
39 messages?: ConversationMessage[];
40};
41
42export type CollectionsDataStoresConversation = Resource<
43 "GCP.DiscoveryEngine.CollectionsDataStoresConversation",
44 CollectionsDataStoresConversationProps,
45 {
46 /** Full resource name. */
47 name: string;
48 /** Conversation id (last path segment). */
49 conversationId: string;
50 /** Parent data store resource name. */
51 dataStore: string;
52 /** Project id. */
53 project: string;
54 /** Location id. */
55 location: string;
56 /** Conversation state. */
57 state: string | undefined;
58 /** User-supplied user pseudo id with the Alchemy token stripped. */
59 userPseudoId: string | undefined;
60 /** Conversation messages. */
61 messages: ConversationMessage[];
62 /** RFC3339 start time. */
63 startTime: string | undefined;
64 /** RFC3339 end time. */
65 endTime: string | undefined;
66 },
67 never,
68 Providers
69>;
70
71/**
72 * A Discovery Engine conversation on a collection data store.
73 *
74 * Its id is assigned by the API, so only a recorded resource can be read
75 * back. The parent data store is immutable. State and messages update in
76 * place. The conversation id is assigned by the API.
77 *
78 * ### Creating a Conversation
79 * **Example:** Open a conversation
80 * ```typescript
81 * const store = yield* GCP.DiscoveryEngine.CollectionsDataStore("Docs", {});
82 * const chat = yield* GCP.DiscoveryEngine.CollectionsDataStoresConversation(
83 * "Visitor",
84 * {
85 * dataStore: store.name,
86 * userPseudoId: "user-1",
87 * },
88 * );
89 * ```
90 *
91 * @resource
92 * @category DiscoveryEngine
93 */
94export const CollectionsDataStoresConversation =
95 Resource<CollectionsDataStoresConversation>(
96 "GCP.DiscoveryEngine.CollectionsDataStoresConversation",
97 );
98
99export class CollectionsDataStoresConversationNotResolved extends Data.TaggedError(
100 "GCP.DiscoveryEngine.CollectionsDataStoresConversationNotResolved",
101)<{
102 name: string;
103}> {}
104
105const toAttrs = (
106 conversation: discoveryengine.GoogleCloudDiscoveryengineV1Conversation,
107 project: string,
108) => {
109 const name = conversation.name ?? "";
110 const parsed = parseResourceName(name, "conversations");
111 return {
112 name,
113 conversationId: parsed.id,
114 dataStore: parentOf(name, "conversations"),
115 project: parsed.project || project,
116 location: parsed.location,
117 state: conversation.state,
118 userPseudoId: conversation.userPseudoId,
119 messages: (conversation.messages ?? []).map((message) => ({
120 userInput: message.userInput
121 ? {
122 input: message.userInput.input,
123 context: message.userInput.context,
124 }
125 : undefined,
126 })),
127 startTime: conversation.startTime,
128 endTime: conversation.endTime,
129 };
130};
131
132const getByName = (name: string) =>
133 name.length === 0
134 ? Effect.succeed(undefined)
135 : discoveryengine
136 .getProjectsLocationsCollectionsDataStoresConversations({ name })
137 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
138
139export const CollectionsDataStoresConversationProvider = () =>
140 Provider.succeed(CollectionsDataStoresConversation, {
141 stables: [
142 "name",
143 "conversationId",
144 "dataStore",
145 "project",
146 "location",
147 "startTime",
148 ],
149
150 diff: Effect.fn(function* ({ news, olds, output }) {
151 if (!isResolved(news)) return undefined;
152 const previousParent = olds?.dataStore ?? output?.dataStore;
153 if (previousParent !== undefined && news.dataStore !== previousParent) {
154 return { action: "replace" as const, deleteFirst: false };
155 }
156 return undefined;
157 }),
158
159 read: Effect.fn(function* ({ output }) {
160 const env = yield* GcpEnvironment.current;
161 // Server-assigned id: only a recorded name can be observed.
162 if (output?.name === undefined) return undefined;
163 const existing = yield* getByName(output.name);
164 if (existing === undefined) return undefined;
165 return toAttrs(existing, env.project);
166 }),
167
168 reconcile: Effect.fn(function* ({ news, output }) {
169 const env = yield* GcpEnvironment.current;
170 const userPseudoId = news.userPseudoId;
171 const state = news.state ?? "IN_PROGRESS";
172
173 let current = yield* getByName(output?.name ?? "");
174
175 if (current === undefined) {
176 const created = yield* discoveryengine
177 .createProjectsLocationsCollectionsDataStoresConversations({
178 parent: news.dataStore,
179 body: {
180 state,
181 userPseudoId,
182 messages: news.messages,
183 },
184 })
185 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
186 current = created ?? undefined;
187 }
188
189 if (current === undefined) {
190 return yield* new CollectionsDataStoresConversationNotResolved({
191 name: output?.name ?? `${news.dataStore}/conversations/-`,
192 });
193 }
194
195 const resource = current.name ?? "";
196 const stateChanged = (current.state ?? "") !== state;
197 const userChanged =
198 userPseudoId !== undefined && current.userPseudoId !== userPseudoId;
199 const messagesChanged = !sameJson(current.messages, news.messages);
200
201 if (stateChanged || userChanged || messagesChanged) {
202 current =
203 yield* discoveryengine.patchProjectsLocationsCollectionsDataStoresConversations(
204 {
205 name: resource,
206 updateMask: [
207 stateChanged ? "state" : undefined,
208 userChanged ? "user_pseudo_id" : undefined,
209 messagesChanged ? "messages" : undefined,
210 ]
211 .filter((field): field is string => field !== undefined)
212 .join(","),
213 body: {
214 name: resource,
215 state,
216 userPseudoId,
217 messages: news.messages,
218 },
219 },
220 );
221 }
222
223 return toAttrs(current, env.project);
224 }),
225
226 delete: Effect.fn(function* ({ output }) {
227 yield* discoveryengine
228 .deleteProjectsLocationsCollectionsDataStoresConversations({
229 name: output.name,
230 })
231 .pipe(Effect.catchTag("NotFound", () => Effect.void));
232 }),
233 });