Skip to content

Commit f761374

Browse files
author
David Motsonashvili
committed
add TemplateChat and TemplateChatFutures
1 parent 6c4050c commit f761374

File tree

5 files changed

+311
-9
lines changed

5 files changed

+311
-9
lines changed

firebase-ai/api.txt

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,18 @@ package com.google.firebase.ai {
8787
method public suspend Object? connect(kotlin.coroutines.Continuation<? super com.google.firebase.ai.type.LiveSession>);
8888
}
8989

90+
@com.google.firebase.ai.type.PublicPreviewAPI public final class TemplateChat {
91+
ctor public TemplateChat(com.google.firebase.ai.TemplateGenerativeModel model, String templateID, java.util.List<com.google.firebase.ai.type.Content> history = java.util.ArrayList());
92+
method public java.util.List<com.google.firebase.ai.type.Content> getHistory();
93+
method public suspend Object? sendMessage(com.google.firebase.ai.type.Content prompt, java.util.Map<java.lang.String,?> inputs, kotlin.coroutines.Continuation<? super com.google.firebase.ai.type.GenerateContentResponse>);
94+
method public kotlinx.coroutines.flow.Flow<com.google.firebase.ai.type.GenerateContentResponse> sendMessageStream(com.google.firebase.ai.type.Content prompt, java.util.Map<java.lang.String,?> inputs);
95+
property public final java.util.List<com.google.firebase.ai.type.Content> history;
96+
}
97+
9098
@com.google.firebase.ai.type.PublicPreviewAPI public final class TemplateGenerativeModel {
91-
method public suspend Object? generateContent(String templateId, java.util.Map<java.lang.String,?> inputs, kotlin.coroutines.Continuation<? super com.google.firebase.ai.type.GenerateContentResponse>);
92-
method public kotlinx.coroutines.flow.Flow<com.google.firebase.ai.type.GenerateContentResponse> generateContentStream(String templateId, java.util.Map<java.lang.String,?> inputs);
99+
method public suspend Object? generateContent(String templateId, java.util.Map<java.lang.String,?> inputs, java.util.List<com.google.firebase.ai.type.Content>? history = null, kotlin.coroutines.Continuation<? super com.google.firebase.ai.type.GenerateContentResponse>);
100+
method public kotlinx.coroutines.flow.Flow<com.google.firebase.ai.type.GenerateContentResponse> generateContentStream(String templateId, java.util.Map<java.lang.String,?> inputs, java.util.List<com.google.firebase.ai.type.Content>? history = null);
101+
method public com.google.firebase.ai.TemplateChat startChat(String templateId, java.util.List<com.google.firebase.ai.type.Content> history = emptyList());
93102
}
94103

95104
@com.google.firebase.ai.type.PublicPreviewAPI public final class TemplateImagenModel {
@@ -179,6 +188,18 @@ package com.google.firebase.ai.java {
179188
method public com.google.firebase.ai.java.LiveSessionFutures from(com.google.firebase.ai.type.LiveSession session);
180189
}
181190

191+
@com.google.firebase.ai.type.PublicPreviewAPI public abstract class TemplateChatFutures {
192+
method public static final com.google.firebase.ai.java.TemplateChatFutures from(com.google.firebase.ai.TemplateChat chat);
193+
method public abstract com.google.firebase.ai.TemplateChat getChat();
194+
method public abstract com.google.common.util.concurrent.ListenableFuture<com.google.firebase.ai.type.GenerateContentResponse> sendMessage(com.google.firebase.ai.type.Content prompt, java.util.Map<java.lang.String,?> inputs);
195+
method public abstract org.reactivestreams.Publisher<com.google.firebase.ai.type.GenerateContentResponse> sendMessageStream(com.google.firebase.ai.type.Content prompt, java.util.Map<java.lang.String,?> inputs);
196+
field public static final com.google.firebase.ai.java.TemplateChatFutures.Companion Companion;
197+
}
198+
199+
public static final class TemplateChatFutures.Companion {
200+
method public com.google.firebase.ai.java.TemplateChatFutures from(com.google.firebase.ai.TemplateChat chat);
201+
}
202+
182203
public abstract class TemplateGenerativeModelFutures {
183204
method public static final com.google.firebase.ai.java.TemplateGenerativeModelFutures from(com.google.firebase.ai.TemplateGenerativeModel model);
184205
method public abstract com.google.common.util.concurrent.ListenableFuture<com.google.firebase.ai.type.GenerateContentResponse> generateContent(String templateId, java.util.Map<java.lang.String,?> inputs);
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.ai
18+
19+
import android.graphics.Bitmap
20+
import com.google.firebase.ai.type.Content
21+
import com.google.firebase.ai.type.GenerateContentResponse
22+
import com.google.firebase.ai.type.ImagePart
23+
import com.google.firebase.ai.type.InlineDataPart
24+
import com.google.firebase.ai.type.InvalidStateException
25+
import com.google.firebase.ai.type.PublicPreviewAPI
26+
import com.google.firebase.ai.type.TextPart
27+
import com.google.firebase.ai.type.content
28+
import java.util.LinkedList
29+
import java.util.concurrent.Semaphore
30+
import kotlinx.coroutines.flow.Flow
31+
import kotlinx.coroutines.flow.onCompletion
32+
import kotlinx.coroutines.flow.onEach
33+
34+
/**
35+
* Representation of a multi-turn interaction with a model.
36+
*
37+
* Captures and stores the history of communication in memory, and provides it as context with each
38+
* new message.
39+
*
40+
* **Note:** This object is not thread-safe, and calling [sendMessage] multiple times without
41+
* waiting for a response will throw an [InvalidStateException].
42+
*
43+
* @param model The model to use for the interaction.
44+
* @param templateID The template ID for this chat session
45+
* @property history The previous content from the chat that has been successfully sent and received
46+
* from the model. This will be provided to the model for each message sent (as context for the
47+
* discussion).
48+
*/
49+
@PublicPreviewAPI
50+
public class TemplateChat(
51+
private val model: TemplateGenerativeModel,
52+
private val templateID: String,
53+
public val history: MutableList<Content> = ArrayList()
54+
) {
55+
private var lock = Semaphore(1)
56+
57+
/**
58+
* Sends a message using the provided [prompt]; automatically providing the existing [history] as
59+
* context.
60+
*
61+
* If successful, the message and response will be added to the [history]. If unsuccessful,
62+
* [history] will remain unchanged.
63+
*
64+
* @param prompt The input that, together with the history, will be given to the model as the
65+
* prompt.
66+
* @param inputs the inputs needed to fill in the template ID
67+
* @throws InvalidStateException if [prompt] is not coming from the 'user' role.
68+
* @throws InvalidStateException if the [Chat] instance has an active request.
69+
*/
70+
public suspend fun sendMessage(
71+
prompt: Content,
72+
inputs: Map<String, Any>
73+
): GenerateContentResponse {
74+
prompt.assertComesFromUser()
75+
attemptLock()
76+
try {
77+
val fullPrompt = history + prompt
78+
val response = model.generateContent(templateID, inputs, fullPrompt)
79+
history.add(prompt)
80+
history.add(response.candidates.first().content)
81+
return response
82+
} finally {
83+
lock.release()
84+
}
85+
}
86+
87+
/**
88+
* Sends a message using the existing history of this chat as context and the provided [Content]
89+
* prompt.
90+
*
91+
* The response from the model is returned as a stream.
92+
*
93+
* If successful, the message and response will be added to the history. If unsuccessful, history
94+
* will remain unchanged.
95+
*
96+
* @param prompt The input that, together with the history, will be given to the model as the
97+
* prompt.
98+
* @param inputs the inputs needed to fill in the template ID
99+
* @throws InvalidStateException if [prompt] is not coming from the 'user' role.
100+
* @throws InvalidStateException if the [Chat] instance has an active request.
101+
*/
102+
public fun sendMessageStream(
103+
prompt: Content,
104+
inputs: Map<String, Any>
105+
): Flow<GenerateContentResponse> {
106+
prompt.assertComesFromUser()
107+
attemptLock()
108+
109+
val fullPrompt = history + prompt
110+
val flow = model.generateContentStream(templateID, inputs, fullPrompt)
111+
val bitmaps = LinkedList<Bitmap>()
112+
val inlineDataParts = LinkedList<InlineDataPart>()
113+
val text = StringBuilder()
114+
115+
/**
116+
* TODO: revisit when images and inline data are returned. This will cause issues with how
117+
* things are structured in the response. eg; a text/image/text response will be (incorrectly)
118+
* represented as image/text
119+
*/
120+
return flow
121+
.onEach {
122+
for (part in it.candidates.first().content.parts) {
123+
when (part) {
124+
is TextPart -> text.append(part.text)
125+
is ImagePart -> bitmaps.add(part.image)
126+
is InlineDataPart -> inlineDataParts.add(part)
127+
}
128+
}
129+
}
130+
.onCompletion {
131+
lock.release()
132+
if (it == null) {
133+
val content =
134+
content("model") {
135+
for (bitmap in bitmaps) {
136+
image(bitmap)
137+
}
138+
for (inlineDataPart in inlineDataParts) {
139+
inlineData(inlineDataPart.inlineData, inlineDataPart.mimeType)
140+
}
141+
if (text.isNotBlank()) {
142+
text(text.toString())
143+
}
144+
}
145+
146+
history.add(prompt)
147+
history.add(content)
148+
}
149+
}
150+
}
151+
152+
private fun Content.assertComesFromUser() {
153+
if (role !in listOf("user", "function")) {
154+
throw InvalidStateException("Chat prompts should come from the 'user' or 'function' role.")
155+
}
156+
}
157+
158+
private fun attemptLock() {
159+
if (!lock.tryAcquire()) {
160+
throw InvalidStateException(
161+
"This chat instance currently has an ongoing request, please wait for it to complete " +
162+
"before sending more messages"
163+
)
164+
}
165+
}
166+
}

firebase-ai/src/main/kotlin/com/google/firebase/ai/TemplateGenerativeModel.kt

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.google.firebase.FirebaseApp
2020
import com.google.firebase.ai.common.APIController
2121
import com.google.firebase.ai.common.AppCheckHeaderProvider
2222
import com.google.firebase.ai.common.TemplateGenerateContentRequest
23+
import com.google.firebase.ai.type.Content
2324
import com.google.firebase.ai.type.FinishReason
2425
import com.google.firebase.ai.type.FirebaseAIException
2526
import com.google.firebase.ai.type.GenerateContentResponse
@@ -78,17 +79,19 @@ internal constructor(
7879
*
7980
* @param templateId The ID of server prompt template.
8081
* @param inputs the inputs needed to fill in the prompt
82+
* @param history history to be passed to the model (for multi-turn generation)
8183
* @return The content generated by the model.
8284
* @throws [FirebaseAIException] if the request failed.
8385
* @see [FirebaseAIException] for types of errors.
8486
*/
8587
public suspend fun generateContent(
8688
templateId: String,
87-
inputs: Map<String, Any>
89+
inputs: Map<String, Any>,
90+
history: List<Content>? = null
8891
): GenerateContentResponse =
8992
try {
9093
controller
91-
.templateGenerateContent("$templateUri$templateId", constructRequest(inputs))
94+
.templateGenerateContent("$templateUri$templateId", constructRequest(inputs, history))
9295
.toPublic()
9396
.validate()
9497
} catch (e: Throwable) {
@@ -100,22 +103,32 @@ internal constructor(
100103
*
101104
* @param templateId The ID of server prompt template.
102105
* @param inputs the inputs needed to fill in the prompt
106+
* @param history history to be passed to the model (for multi-turn generation)
103107
* @return A [Flow] which will emit responses as they are returned by the model.
104108
* @throws [FirebaseAIException] if the request failed.
105109
* @see [FirebaseAIException] for types of errors.
106110
*/
107111
public fun generateContentStream(
108112
templateId: String,
109-
inputs: Map<String, Any>
113+
inputs: Map<String, Any>,
114+
history: List<Content>? = null
110115
): Flow<GenerateContentResponse> =
111116
controller
112-
.templateGenerateContentStream("$templateUri$templateId", constructRequest(inputs))
117+
.templateGenerateContentStream("$templateUri$templateId", constructRequest(inputs, history))
113118
.catch { throw FirebaseAIException.from(it) }
114119
.map { it.toPublic().validate() }
115120

116-
internal fun constructRequest(inputs: Map<String, Any>): TemplateGenerateContentRequest {
121+
/** Creates a [TemplateChat] instance using this model with the optionally provided history. */
122+
public fun startChat(templateId: String, history: List<Content> = emptyList()): TemplateChat =
123+
TemplateChat(this, templateId, history.toMutableList())
124+
125+
internal fun constructRequest(
126+
inputs: Map<String, Any>,
127+
history: List<Content>? = null
128+
): TemplateGenerateContentRequest {
117129
return TemplateGenerateContentRequest(
118-
Json.parseToJsonElement(JSONObject(inputs).toString()).jsonObject
130+
Json.parseToJsonElement(JSONObject(inputs).toString()).jsonObject,
131+
history?.let { it.map { it.toInternal() } }
119132
)
120133
}
121134

firebase-ai/src/main/kotlin/com/google/firebase/ai/common/Request.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ internal data class GenerateContentRequest(
4646
@SerialName("system_instruction") val systemInstruction: Content.Internal? = null,
4747
) : Request
4848

49-
@Serializable internal data class TemplateGenerateContentRequest(val inputs: JsonObject) : Request
49+
@Serializable
50+
internal data class TemplateGenerateContentRequest(
51+
val inputs: JsonObject,
52+
val history: List<Content.Internal>?
53+
) : Request
5054

5155
@Serializable internal data class TemplateGenerateImageRequest(val inputs: JsonObject) : Request
5256

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.firebase.ai.java
18+
19+
import androidx.concurrent.futures.SuspendToFutureAdapter
20+
import com.google.common.util.concurrent.ListenableFuture
21+
import com.google.firebase.ai.TemplateChat
22+
import com.google.firebase.ai.type.Content
23+
import com.google.firebase.ai.type.GenerateContentResponse
24+
import com.google.firebase.ai.type.InvalidStateException
25+
import com.google.firebase.ai.type.PublicPreviewAPI
26+
import kotlinx.coroutines.reactive.asPublisher
27+
import org.reactivestreams.Publisher
28+
29+
/**
30+
* Wrapper class providing Java compatible methods for [TemplateChat].
31+
*
32+
* @see [TemplateChat]
33+
*/
34+
@PublicPreviewAPI
35+
public abstract class TemplateChatFutures internal constructor() {
36+
37+
/**
38+
* Sends a message using the existing history of this chat as context and the provided [Content]
39+
* prompt.
40+
*
41+
* If successful, the message and response will be added to the history. If unsuccessful, history
42+
* will remain unchanged.
43+
*
44+
* @param prompt The input that, together with the history, will be given to the model as the
45+
* prompt.
46+
* @param inputs the inputs needed to fill in the template ID
47+
* @throws InvalidStateException if [prompt] is not coming from the 'user' role
48+
* @throws InvalidStateException if the [TemplateChat] instance has an active request
49+
*/
50+
public abstract fun sendMessage(
51+
prompt: Content,
52+
inputs: Map<String, Any>
53+
): ListenableFuture<GenerateContentResponse>
54+
55+
/**
56+
* Sends a message using the existing history of this chat as context and the provided [Content]
57+
* prompt.
58+
*
59+
* The response from the model is returned as a stream.
60+
*
61+
* If successful, the message and response will be added to the history. If unsuccessful, history
62+
* will remain unchanged.
63+
*
64+
* @param prompt The input that, together with the history, will be given to the model as the
65+
* prompt.
66+
* @param inputs the inputs needed to fill in the template ID
67+
* @throws InvalidStateException if [prompt] is not coming from the 'user' role
68+
* @throws InvalidStateException if the [TemplateChat] instance has an active request
69+
*/
70+
public abstract fun sendMessageStream(
71+
prompt: Content,
72+
inputs: Map<String, Any>
73+
): Publisher<GenerateContentResponse>
74+
75+
/** Returns the [TemplateChat] object wrapped by this object. */
76+
public abstract fun getChat(): TemplateChat
77+
78+
private class FuturesImpl(private val chat: TemplateChat) : TemplateChatFutures() {
79+
override fun sendMessage(
80+
prompt: Content,
81+
inputs: Map<String, Any>
82+
): ListenableFuture<GenerateContentResponse> =
83+
SuspendToFutureAdapter.launchFuture { chat.sendMessage(prompt, inputs) }
84+
85+
override fun sendMessageStream(
86+
prompt: Content,
87+
inputs: Map<String, Any>
88+
): Publisher<GenerateContentResponse> = chat.sendMessageStream(prompt, inputs).asPublisher()
89+
90+
override fun getChat(): TemplateChat = chat
91+
}
92+
93+
public companion object {
94+
95+
/** @return a [TemplateChatFutures] created around the provided [TemplateChat] */
96+
@JvmStatic public fun from(chat: TemplateChat): TemplateChatFutures = FuturesImpl(chat)
97+
}
98+
}

0 commit comments

Comments
 (0)