Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cf8add9
fix(dotAI): update default image model, fix playground overflow, fix …
ihoffmann-dot May 14, 2026
88ba866
fix embeddings host resolution in BulkEmbeddingsRunner and cap image …
ihoffmann-dot May 14, 2026
546cc42
fix search form validation, model dropdown, and missing index error r…
ihoffmann-dot May 18, 2026
903196d
add integration tests for embeddings host resolution with site-only A…
ihoffmann-dot May 18, 2026
986ee28
Merge branch 'main' into dot-ai-langchain-qa-fixes
ihoffmann-dot May 18, 2026
afac943
Merge branch 'main' into dot-ai-langchain-qa-fixes
ihoffmann-dot May 19, 2026
d45ba87
fix(dotAI): address Kevin's review comments on image size and model d…
ihoffmann-dot May 21, 2026
c598a2c
fix: parse multimodal content arrays in toMessages() so vision prompt…
ihoffmann-dot May 21, 2026
28fbdd7
refactor: extract toImageContent helper from toMultimodalUserMessage
ihoffmann-dot May 21, 2026
17b7bc1
fix: address Claude review — indexExists probe, toImageContent NPE gu…
ihoffmann-dot May 21, 2026
4c163b7
Merge branch 'main' into dot-ai-langchain-qa-fixes
ihoffmann-dot May 21, 2026
1295436
fix: resolve host-specific AppConfig in workflow AI runners
ihoffmann-dot May 21, 2026
01908e3
fix: resolve host-specific AppConfig in workflow AI runners
ihoffmann-dot May 21, 2026
539f673
Merge branch 'main' into dot-ai-langchain-qa-fixes
ihoffmann-dot May 21, 2026
361b170
fix: pass host-specific AppConfig to getCompletionsAPI in OpenAITrans…
ihoffmann-dot May 21, 2026
6fa9c84
Merge branch 'main' into dot-ai-langchain-qa-fixes
ihoffmann-dot May 21, 2026
0492c0a
fix: update example imageSize to 1024x1024 in AI config detail
ihoffmann-dot May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const EXAMPLE_CONFIG = {
image: {
provider: 'openai',
apiKey: 'sk-...',
model: 'dall-e-3'
model: 'gpt-image-1'
},
settings: {
rolePrompt: 'You are dotCMSbot, an AI assistant to help content creators.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.dotcms.ai.rest.forms.EmbeddingsForm;
import com.dotcms.contenttype.model.field.Field;
import com.dotmarketing.beans.Host;
import com.dotmarketing.business.APILocator;
import com.dotmarketing.portlets.contentlet.model.Contentlet;
import com.dotmarketing.util.Logger;
Expand Down Expand Up @@ -47,12 +48,16 @@ public void run() {
.stream()
.filter(f -> embeddingsForm.fieldsAsList().contains(f.variable().toLowerCase()))
.collect(Collectors.toList());
final Host host = Try
.of(() -> APILocator.getHostAPI().find(
contentlet.getHost(), APILocator.systemUser(), false))
.getOrElse(APILocator.systemHost());
// if a velocity template is passed in, use it. Otherwise, try the fields
if (!APILocator.getDotAIAPI().getEmbeddingsAPI().generateEmbeddingsForContent(
if (!APILocator.getDotAIAPI().getEmbeddingsAPI(host).generateEmbeddingsForContent(
Comment thread
ihoffmann-dot marked this conversation as resolved.
contentlet,
embeddingsForm.velocityTemplate,
embeddingsForm.indexName)) {
APILocator.getDotAIAPI().getEmbeddingsAPI().generateEmbeddingsForContent(contentlet, fields, embeddingsForm.indexName);
APILocator.getDotAIAPI().getEmbeddingsAPI(host).generateEmbeddingsForContent(contentlet, fields, embeddingsForm.indexName);
}
} catch (Exception e) {
Logger.warn(this.getClass(), "unable to embed content:" + inode + " error:" + e.getMessage(), e);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.dotcms.ai.listener;

import com.dotcms.ai.api.EmbeddingsAPI;
import com.dotcms.ai.app.AppConfig;
import com.dotcms.ai.app.ConfigService;
import com.dotcms.ai.db.EmbeddingsDTO;
Expand Down Expand Up @@ -78,12 +79,14 @@ public void onDeleted(final ContentletDeletedEvent<Contentlet> contentletDeleted
deleteFromIndexes(contentlet);
}

private AppConfig getAppConfig(final String hostId) {
final Host host = Try
private Host resolveHost(final String hostId) {
return Try
.of(() -> APILocator.getHostAPI().find(hostId, APILocator.systemUser(), false))
.getOrElse(APILocator.systemHost());
}

final AppConfig appConfig = ConfigService.INSTANCE.config(host);
private AppConfig getAppConfig(final String hostId) {
final AppConfig appConfig = ConfigService.INSTANCE.config(resolveHost(hostId));
if (!appConfig.isEnabled()) {
appConfig.debugLogger(
getClass(),
Expand Down Expand Up @@ -121,17 +124,18 @@ private void addToIndexesIfNeeded(final Contentlet contentlet) {
}

try {
final Host host = resolveHost(contentlet.getHost());
final JSONObject config = getConfigJson(contentlet.getHost());

for (final Entry<String, Object> entry : (Set<Entry<String, Object>>) config.entrySet()) {
final String indexName = entry.getKey();
final EmbeddingsAPI embeddingsAPI = APILocator.getDotAIAPI().getEmbeddingsAPI(host);
final Map<String, List<Field>> typesAndFields =
APILocator.getDotAIAPI().getEmbeddingsAPI().parseTypesAndFields((String) entry.getValue());
embeddingsAPI.parseTypesAndFields((String) entry.getValue());
typesAndFields.entrySet()
.stream()
.filter(typeFields -> contentType.equalsIgnoreCase(typeFields.getKey()))
.forEach(e -> APILocator.getDotAIAPI().getEmbeddingsAPI()
.generateEmbeddingsForContent(
.forEach(e -> embeddingsAPI.generateEmbeddingsForContent(
contentlet,
e.getValue(),
indexName));
Expand All @@ -149,14 +153,15 @@ private void addToIndexesIfNeeded(final Contentlet contentlet) {
@WrapInTransaction
private void deleteFromIndexes(final Contentlet contentlet) {
try {
final Host host = resolveHost(contentlet.getHost());
getConfigJson(contentlet.getHost());

final EmbeddingsDTO dto = new EmbeddingsDTO.Builder()
.withIdentifier(contentlet.getIdentifier())
.withLanguage(contentlet.getLanguageId())
.withIndexName(ALL_INDICES)
.build();
APILocator.getDotAIAPI().getEmbeddingsAPI().deleteEmbedding(dto);
APILocator.getDotAIAPI().getEmbeddingsAPI(host).deleteEmbedding(dto);
} catch (final Exception e) {
Logger.error(getClass(), "Error deleting content from embeddings index: " + e.getMessage(), e);
throw new DotRuntimeException("Error deleting content from embeddings index: " + e.getMessage(), e);
Expand Down
12 changes: 11 additions & 1 deletion dotCMS/src/main/java/com/dotcms/ai/rest/SearchResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,20 @@ public final Response searchByPost(@Context final HttpServletRequest request,
.init()
.getUser();

final Host host = WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request);
final Map<String, Map<String, Object>> knownIndexes =
APILocator.getDotAIAPI().getEmbeddingsAPI(host).countEmbeddingsByIndex();
if (!knownIndexes.containsKey(form.indexName)) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of(AiKeys.ERROR, "Index '" + form.indexName + "' not found. Known indexes: " + knownIndexes.keySet()))
.type(MediaType.APPLICATION_JSON)
.build();
}

final EmbeddingsDTO searcher = EmbeddingsDTO.from(form).withUser(user).build();

return Response.ok(
APILocator.getDotAIAPI().getEmbeddingsAPI().searchForContent(searcher).toString(),
APILocator.getDotAIAPI().getEmbeddingsAPI(host).searchForContent(searcher).toString(),
MediaType.APPLICATION_JSON).build();
}

Expand Down
6 changes: 3 additions & 3 deletions dotCMS/src/main/webapp/WEB-INF/jsp/dotai/render.jsp
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@
Content index to search:
</th>
<td>
<select name="indexName" id="indexNameChat" style="min-width:400px;">
<option disabled="true" placeholder="Select an Index">Select an Index</option>
<select name="indexName" id="indexNameChat" required style="min-width:400px;">
<option disabled value="">Select an Index</option>
</select>
</td>
</tr>
Expand Down Expand Up @@ -98,7 +98,7 @@
</th>
<td><span class="clearPromptX" id="searchQueryX" onclick="clearPrompt('searchQuery')"
style="visibility: hidden">&#10006;</span>
<textarea class="prompt" name="prompt" id="searchQuery"
<textarea class="prompt" name="prompt" id="searchQuery" required
onkeyup="showClearPrompt('searchQuery')"
onchange="showClearPrompt('searchQuery')"
placeholder="Search text or phrase"></textarea>
Expand Down
48 changes: 25 additions & 23 deletions dotCMS/src/main/webapp/html/portlet/ext/dotai/dotai.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,26 +127,24 @@ const writeIndexesToDropdowns = async () => {

const writeModelToDropdown = async () => {
const modelName = document.getElementById("modelName");
let options = modelName.getElementsByTagName('option');
modelName.innerHTML = '<option disabled value="">Select a Model</option>';

for (i = 1; i < options.length; i++) {
indexName.removeChild(options[i]);
let models = [];
try {
const providerConfig = JSON.parse(dotAiState.config.providerConfig);
const chatModel = providerConfig?.chat?.model ?? '';
models = chatModel.split(',').map(m => m.trim()).filter(m => m.length > 0);
} catch (e) {
Comment thread
ihoffmann-dot marked this conversation as resolved.
// providerConfig missing or not valid JSON
}

for (i = 0; i < dotAiState.config.availableModels.length; i++) {
if (dotAiState.config.availableModels[i].type !== 'TEXT') {
continue;
}

const newOption = document.createElement("option");
newOption.value = dotAiState.config.availableModels[i].name;
newOption.text = `${dotAiState.config.availableModels[i].name}`
if (dotAiState.config.availableModels[i].current) {
newOption.selected = true;
newOption.text = `${dotAiState.config.availableModels[i].name} (default)`
}
modelName.appendChild(newOption);
}
models.forEach((model, index) => {
Comment thread
ihoffmann-dot marked this conversation as resolved.
const opt = document.createElement("option");
opt.value = model;
opt.text = index === 0 ? `${model} (default)` : model;
if (index === 0) opt.selected = true;
modelName.appendChild(opt);
});
};


Expand Down Expand Up @@ -566,27 +564,28 @@ const doImageJsonDebounced = async () => {
const temp = json.response;
const width = formData.size.split("x")[0];
const height = formData.size.split("x")[1];
const jsonString=JSON.stringify(json, 2);
const jsonString = JSON.stringify(json, null, 2);
const rewrittenPrompt = json.revised_prompt;
const imageTemplate =`
<div style="width:100%;max-width:800px;position:relative;text-align:center;border:1px solid silver;padding:1rem;">
<div style="width:100%;max-width:800px;position:relative;text-align:center;border:1px solid silver;padding:1rem;overflow:hidden;">
<a href="/dA/${temp}/asset.png" target="_blank">
<img src="/dA/${temp}/asset.png" style="max-width:750px;max-height:750px;display: block;margin:auto;" />
</a>

<div style="padding:1rem;margin:auto;text-align: center">
<button id="saveImageButton" class="button dijit dijitReset dijitInline dijitButton"
onclick="saveImage('${temp}')">
Save
</button><br>
<div id="imageSavedMessage">&nbsp;</div>
</div>
<div style="border:1px solid silver;padding:1rem;margin:auto;text-align: left">
<div style="border:1px solid silver;padding:1rem;margin:auto;text-align: left;overflow-wrap:break-word;word-break:break-word;">
<b>OpenAI Prompt (Rewritten):</b> <br>
${rewrittenPrompt}
</div>
<div style="border:1px solid silver;padding:1rem;margin:auto;text-align: left">
<b>JSON Response:</b> <br>${jsonString}
<div style="border:1px solid silver;padding:1rem;margin:auto;text-align: left;">
<b>JSON Response:</b>
<pre style="white-space:pre-wrap;word-break:break-word;overflow-x:auto;overflow-y:auto;max-height:300px;margin:0.5rem 0 0 0;font-size:0.85em;">${jsonString}</pre>
</div>
</div>

Expand Down Expand Up @@ -646,6 +645,9 @@ const clearSaveMessage =() =>{


const doSearchChatJson = () => {
if (!document.getElementById("chatForm").reportValidity()) {
return;
}
document.getElementById("submitChat").style.display = "none";
document.getElementById("loaderChat").style.display = "block";
setTimeout(function () {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.dotcms.ai.api;

import com.dotcms.ai.AiTest;
import com.dotcms.contenttype.model.type.ContentType;
import com.dotcms.datagen.SiteDataGen;
import com.dotcms.datagen.TestDataUtils;
import com.dotcms.ai.rest.forms.EmbeddingsForm;
import com.dotcms.util.IntegrationTestInitService;
import com.dotcms.util.network.IPUtils;
import com.dotmarketing.beans.Host;
import com.dotmarketing.business.APILocator;
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.exception.DotSecurityException;
import com.dotmarketing.portlets.contentlet.model.Contentlet;
import com.dotmarketing.portlets.languagesmanager.business.LanguageAPI;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.liferay.portal.model.User;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;

import java.util.List;

import static com.dotmarketing.util.ThreadUtils.sleep;
import static org.junit.Assert.assertTrue;

/**
* Integration tests for {@link BulkEmbeddingsRunner}.
*
* <p>The setup intentionally configures AI secrets only on the site host, not on System Host.
* This validates that the runner resolves the host per-contentlet and uses the correct
* site config rather than falling back to System Host.
*/
public class BulkEmbeddingsRunnerTest {

private static final int MAX_ATTEMPTS = 30;

private static User user;
private static Host host;
private static LanguageAPI languageApi;
private static WireMockServer wireMockServer;
private static ContentType blogContentType;
private static Contentlet contentlet;

@BeforeClass
public static void beforeClass() throws Exception {
IntegrationTestInitService.getInstance().init();
IPUtils.disabledIpPrivateSubnet(true);
user = APILocator.getUserAPI().getSystemUser();
languageApi = APILocator.getLanguageAPI();
host = new SiteDataGen().nextPersisted();
wireMockServer = AiTest.prepareWireMock();
// Configure AI only on the site host — intentionally NOT on System Host
AiTest.aiAppSecretsWithProviderConfig(host, AiTest.providerConfigJson(AiTest.PORT, AiTest.MODEL));
}

@AfterClass
public static void afterClass() throws Exception {
wireMockServer.stop();
IPUtils.disabledIpPrivateSubnet(false);
if (contentlet != null) {
try {
APILocator.getContentletAPI().archive(contentlet, user, false);
APILocator.getContentletAPI().delete(contentlet, user, false);
} catch (DotDataException | DotSecurityException e) {
// ignore
}
}
if (blogContentType != null) {
try {
APILocator.getContentTypeAPI(user).delete(blogContentType);
} catch (DotDataException | DotSecurityException e) {
// ignore
}
}
AiTest.removeAiAppSecrets(host);
}

/**
* Given a contentlet published on a site that has AI configured,
* and System Host does NOT have AI configured,
* When BulkEmbeddingsRunner processes that contentlet,
* Then embeddings should be generated using the site config.
*/
@Test
public void test_run_generatesEmbeddings_withSiteOnlyConfig() throws Exception {
DotAIAPIFacadeImpl.setDefaultEmbeddingsAPIProvider(
new DotAIAPIFacadeImpl.DefaultEmbeddingsAPIProvider());

blogContentType = TestDataUtils.getBlogLikeContentType("blog", host);
final String text = "BulkEmbeddingsRunner should resolve the host from the contentlet "
+ "and use the site config rather than falling back to System Host.";
contentlet = TestDataUtils.withEmbeddings(
true,
host,
languageApi.getDefaultLanguage().getId(),
blogContentType.id(),
text);
APILocator.getContentletAPI().publish(contentlet, user, false);

final EmbeddingsForm form = new EmbeddingsForm.Builder()
.indexName("default")
.build();
new BulkEmbeddingsRunner(List.of(contentlet.getInode()), form).run();

assertTrue(
"Expected embeddings after BulkEmbeddingsRunner.run() with site-only AI config",
waitForEmbeddings(contentlet, text));
}

private static boolean waitForEmbeddings(final Contentlet contentlet, final String text) {
int count = 0;
boolean exists = APILocator.getDotAIAPI().getEmbeddingsAPI(host)
.embeddingExists(contentlet.getInode(), "default", text);
while (!exists) {
if (count++ > MAX_ATTEMPTS) {
break;
}
sleep(500);
exists = APILocator.getDotAIAPI().getEmbeddingsAPI(host)
.embeddingExists(contentlet.getInode(), "default", text);
}
return exists;
}

}
Loading
Loading