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 { parentBefore, parseResourceName, sameJson } from "./internal.ts";
10
11export type CollectionsEnginesConversationProps = {
12 /**
13 * Parent Engine resource name
14 * `projects/{project}/locations/{location}/collections/{collection}/engines/{engine}`.
15 * Immutable — changing it replaces the conversation.
16 */
17 engine: string;
18 /**
19 * User tracking id.
20 */
21 userPseudoId?: string;
22 /**
23 * Conversation state.
24 */
25 state?: discoveryengine.GoogleCloudDiscoveryengineV1ConversationStateEnum;
26 /**
27 * Conversation messages.
28 */
29 messages?: discoveryengine.GoogleCloudDiscoveryengineV1ConversationMessageList;
30};
31
32export type CollectionsEnginesConversation = Resource<
33 "GCP.DiscoveryEngine.CollectionsEnginesConversation",
34 CollectionsEnginesConversationProps,
35 {
36 /** Full resource name. */
37 name: string;
38 /** Conversation id (last path segment). */
39 conversationId: string;
40 /** Parent engine resource name. */
41 engine: string;
42 /** Project id. */
43 project: string;
44 /** Location id. */
45 location: string;
46 /** Collection id. */
47 collectionId: string;
48 /** Tracking id. */
49 userPseudoId: string | undefined;
50 /** Conversation state. */
51 state: string | undefined;
52 /** RFC3339 start timestamp. */
53 startTime: string | undefined;
54 /** RFC3339 end timestamp. */
55 endTime: string | undefined;
56 },
57 never,
58 Providers
59>;
60
61/**
62 * A Discovery Engine Conversation on a collection Engine.
63 *
64 * Its id is assigned by the API, so only a recorded resource can be read
65 * back. The API assigns the conversation id. Parent engine is immutable;
66 * state and messages update in place.
67 *
68 * ### Creating a Conversation
69 * **Example:** Empty conversation
70 * ```typescript
71 * const conversation =
72 * yield* GCP.DiscoveryEngine.CollectionsEnginesConversation("Chat", {
73 * engine: engine.name,
74 * });
75 * ```
76 *
77 * ### Updating a Conversation
78 * **Example:** Complete the conversation
79 * ```typescript
80 * // Same logical id as before; only the changed props differ.
81 * const conversation =
82 * yield* GCP.DiscoveryEngine.CollectionsEnginesConversation("Chat", {
83 * engine: engine.name,
84 * state: "COMPLETED",
85 * });
86 * ```
87 *
88 * @resource
89 * @category DiscoveryEngine
90 */
91export const CollectionsEnginesConversation =
92 Resource<CollectionsEnginesConversation>(
93 "GCP.DiscoveryEngine.CollectionsEnginesConversation",
94 );
95
96export class CollectionsEnginesConversationNotResolved extends Data.TaggedError(
97 "GCP.DiscoveryEngine.CollectionsEnginesConversationNotResolved",
98)<{
99 name: string;
100}> {}
101
102const getByName = (name: string) =>
103 discoveryengine
104 .getProjectsLocationsCollectionsEnginesConversations({ name })
105 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
106
107const toAttrs = (
108 conversation: discoveryengine.GoogleCloudDiscoveryengineV1Conversation,
109 project: string,
110) => {
111 const name = conversation.name ?? "";
112 const parsed = parseResourceName(name, "conversations");
113 return {
114 name,
115 conversationId: parsed.id,
116 engine: parentBefore(name, "conversations"),
117 project: parsed.project || project,
118 location: parsed.location,
119 collectionId: parsed.collectionId,
120 userPseudoId: conversation.userPseudoId,
121 state: conversation.state,
122 startTime: conversation.startTime,
123 endTime: conversation.endTime,
124 };
125};
126
127export const CollectionsEnginesConversationProvider = () =>
128 Provider.succeed(CollectionsEnginesConversation, {
129 stables: [
130 "name",
131 "conversationId",
132 "engine",
133 "project",
134 "location",
135 "collectionId",
136 "startTime",
137 ],
138
139 diff: Effect.fn(function* ({ news, olds, output }) {
140 if (!isResolved(news)) return undefined;
141 const previousEngine = olds?.engine ?? output?.engine;
142 if (previousEngine !== undefined && news.engine !== previousEngine) {
143 return { action: "replace" as const, deleteFirst: false };
144 }
145 return undefined;
146 }),
147
148 read: Effect.fn(function* ({ output }) {
149 const env = yield* GcpEnvironment.current;
150 // Server-assigned id: only a recorded name can be observed.
151 if (output?.name === undefined) return undefined;
152 const existing = yield* getByName(output.name);
153 if (existing === undefined) return undefined;
154 return toAttrs(existing, env.project);
155 }),
156
157 reconcile: Effect.fn(function* ({ news, output }) {
158 const env = yield* GcpEnvironment.current;
159 const userPseudoId = news.userPseudoId;
160 const fallbackName = output?.name ?? `${news.engine}/conversations/-`;
161
162 let current =
163 output?.name !== undefined ? yield* getByName(output.name) : undefined;
164
165 if (current === undefined) {
166 const created = yield* discoveryengine
167 .createProjectsLocationsCollectionsEnginesConversations({
168 parent: news.engine,
169 body: {
170 userPseudoId,
171 state: news.state,
172 messages: news.messages,
173 },
174 })
175 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
176 current = created ?? undefined;
177 }
178
179 if (current === undefined) {
180 return yield* new CollectionsEnginesConversationNotResolved({
181 name: fallbackName,
182 });
183 }
184
185 const name = current.name ?? fallbackName;
186 const userChanged =
187 userPseudoId !== undefined && current.userPseudoId !== userPseudoId;
188 const stateChanged = (current.state ?? "") !== (news.state ?? "");
189 const messagesChanged = !sameJson(current.messages, news.messages);
190
191 if (userChanged || stateChanged || messagesChanged) {
192 current =
193 yield* discoveryengine.patchProjectsLocationsCollectionsEnginesConversations(
194 {
195 name,
196 updateMask: [
197 userChanged ? "user_pseudo_id" : undefined,
198 stateChanged ? "state" : undefined,
199 messagesChanged ? "messages" : undefined,
200 ]
201 .filter((field): field is string => field !== undefined)
202 .join(","),
203 body: {
204 name,
205 userPseudoId,
206 state: news.state,
207 messages: news.messages,
208 },
209 },
210 );
211 }
212
213 return toAttrs(current, env.project);
214 }),
215
216 delete: Effect.fn(function* ({ output }) {
217 const existing = yield* getByName(output.name);
218 if (existing === undefined) return;
219 yield* discoveryengine
220 .deleteProjectsLocationsCollectionsEnginesConversations({
221 name: output.name,
222 })
223 .pipe(Effect.catchTag("NotFound", () => Effect.void));
224 }),
225 });