diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/main/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryAutoConfiguration.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/main/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryAutoConfiguration.java deleted file mode 100644 index e55bc82cea5..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/main/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryAutoConfiguration.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2023-2024 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.cassandra.autoconfigure; - -import com.datastax.oss.driver.api.core.CqlSession; - -import org.springframework.ai.chat.memory.repository.cassandra.CassandraChatMemoryRepository; -import org.springframework.ai.chat.memory.repository.cassandra.CassandraChatMemoryRepositoryConfig; -import org.springframework.ai.model.chat.memory.autoconfigure.ChatMemoryAutoConfiguration; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; - -/** - * {@link AutoConfiguration Auto-configuration} for {@link CassandraChatMemoryRepository}. - * - * @author Mick Semb Wever - * @author Jihoon Kim - * @since 1.0.0 - */ -@AutoConfiguration(after = CassandraAutoConfiguration.class, before = ChatMemoryAutoConfiguration.class) -@ConditionalOnClass({ CassandraChatMemoryRepository.class, CqlSession.class }) -@EnableConfigurationProperties(CassandraChatMemoryRepositoryProperties.class) -public class CassandraChatMemoryRepositoryAutoConfiguration { - - @Bean - @ConditionalOnMissingBean - public CassandraChatMemoryRepository cassandraChatMemoryRepository( - CassandraChatMemoryRepositoryProperties properties, CqlSession cqlSession) { - - var builder = CassandraChatMemoryRepositoryConfig.builder().withCqlSession(cqlSession); - - builder = builder.withKeyspaceName(properties.getKeyspace()) - .withTableName(properties.getTable()) - .withMessagesColumnName(properties.getMessagesColumn()); - - if (!properties.isInitializeSchema()) { - builder = builder.disallowSchemaChanges(); - } - if (null != properties.getTimeToLive()) { - builder = builder.withTimeToLive(properties.getTimeToLive()); - } - - return CassandraChatMemoryRepository.create(builder.build()); - } - -} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/main/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryProperties.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/main/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryProperties.java deleted file mode 100644 index 7b7469dbb0b..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/main/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryProperties.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2023-2024 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.cassandra.autoconfigure; - -import java.time.Duration; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.springframework.ai.chat.memory.repository.cassandra.CassandraChatMemoryRepositoryConfig; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.lang.Nullable; - -/** - * Configuration properties for Cassandra chat memory. - * - * @author Mick Semb Wever - * @author Jihoon Kim - * @since 1.0.0 - */ -@ConfigurationProperties(CassandraChatMemoryRepositoryProperties.CONFIG_PREFIX) -public class CassandraChatMemoryRepositoryProperties { - - public static final String CONFIG_PREFIX = "spring.ai.chat.memory.repository.cassandra"; - - private static final Logger logger = LoggerFactory.getLogger(CassandraChatMemoryRepositoryProperties.class); - - private String keyspace = CassandraChatMemoryRepositoryConfig.DEFAULT_KEYSPACE_NAME; - - private String table = CassandraChatMemoryRepositoryConfig.DEFAULT_TABLE_NAME; - - private String messagesColumn = CassandraChatMemoryRepositoryConfig.DEFAULT_MESSAGES_COLUMN_NAME; - - private boolean initializeSchema = true; - - public boolean isInitializeSchema() { - return this.initializeSchema; - } - - public void setInitializeSchema(boolean initializeSchema) { - this.initializeSchema = initializeSchema; - } - - private Duration timeToLive = null; - - public String getKeyspace() { - return this.keyspace; - } - - public void setKeyspace(String keyspace) { - this.keyspace = keyspace; - } - - public String getTable() { - return this.table; - } - - public void setTable(String table) { - this.table = table; - } - - public String getMessagesColumn() { - return this.messagesColumn; - } - - public void setMessagesColumn(String messagesColumn) { - this.messagesColumn = messagesColumn; - } - - @Nullable - public Duration getTimeToLive() { - return this.timeToLive; - } - - public void setTimeToLive(Duration timeToLive) { - this.timeToLive = timeToLive; - } - -} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/test/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryAutoConfigurationIT.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/test/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryAutoConfigurationIT.java deleted file mode 100644 index 9b619ce9039..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/test/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryAutoConfigurationIT.java +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2023-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.cassandra.autoconfigure; - -import java.time.Duration; -import java.util.List; - -import com.datastax.driver.core.utils.UUIDs; -import org.junit.jupiter.api.Test; -import org.testcontainers.cassandra.CassandraContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -import org.springframework.ai.chat.memory.repository.cassandra.CassandraChatMemoryRepository; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.MessageType; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * @author Mick Semb Wever - * @author Jihoon Kim - * @since 1.0.0 - */ -@Testcontainers -class CassandraChatMemoryRepositoryAutoConfigurationIT { - - static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("cassandra"); - - @Container - static CassandraContainer cassandraContainer = new CassandraContainer(DEFAULT_IMAGE_NAME.withTag("5.0")); - - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(CassandraChatMemoryRepositoryAutoConfiguration.class, - CassandraAutoConfiguration.class)) - .withPropertyValues("spring.ai.chat.memory.repository.cassandra.keyspace=test_autoconfigure"); - - @Test - void addAndGet() { - this.contextRunner.withPropertyValues("spring.cassandra.contactPoints=" + getContactPointHost()) - .withPropertyValues("spring.cassandra.port=" + getContactPointPort()) - .withPropertyValues("spring.cassandra.localDatacenter=" + cassandraContainer.getLocalDatacenter()) - .withPropertyValues("spring.ai.chat.memory.repository.cassandra.time-to-live=" + getTimeToLive()) - .run(context -> { - CassandraChatMemoryRepository memory = context.getBean(CassandraChatMemoryRepository.class); - - String sessionId = UUIDs.timeBased().toString(); - assertThat(memory.findByConversationId(sessionId)).isEmpty(); - - memory.saveAll(sessionId, List.of(new UserMessage("test question"))); - - assertThat(memory.findByConversationId(sessionId)).hasSize(1); - assertThat(memory.findByConversationId(sessionId).get(0).getMessageType()).isEqualTo(MessageType.USER); - assertThat(memory.findByConversationId(sessionId).get(0).getText()).isEqualTo("test question"); - - memory.deleteByConversationId(sessionId); - assertThat(memory.findByConversationId(sessionId)).isEmpty(); - - memory.saveAll(sessionId, - List.of(new UserMessage("test question"), new AssistantMessage("test answer"))); - - assertThat(memory.findByConversationId(sessionId)).hasSize(2); - assertThat(memory.findByConversationId(sessionId).get(1).getMessageType()) - .isEqualTo(MessageType.ASSISTANT); - assertThat(memory.findByConversationId(sessionId).get(1).getText()).isEqualTo("test answer"); - assertThat(memory.findByConversationId(sessionId).get(0).getMessageType()).isEqualTo(MessageType.USER); - assertThat(memory.findByConversationId(sessionId).get(0).getText()).isEqualTo("test question"); - - CassandraChatMemoryRepositoryProperties properties = context - .getBean(CassandraChatMemoryRepositoryProperties.class); - assertThat(properties.getTimeToLive()).isEqualTo(getTimeToLive()); - }); - } - - @Test - void compareTimeToLive_ISO8601Format() { - this.contextRunner.withPropertyValues("spring.cassandra.contactPoints=" + getContactPointHost()) - .withPropertyValues("spring.cassandra.port=" + getContactPointPort()) - .withPropertyValues("spring.cassandra.localDatacenter=" + cassandraContainer.getLocalDatacenter()) - .withPropertyValues("spring.ai.chat.memory.repository.cassandra.time-to-live=" + getTimeToLiveString()) - .run(context -> { - CassandraChatMemoryRepositoryProperties properties = context - .getBean(CassandraChatMemoryRepositoryProperties.class); - assertThat(properties.getTimeToLive()).isEqualTo(Duration.parse(getTimeToLiveString())); - }); - } - - private String getContactPointHost() { - return cassandraContainer.getContactPoint().getHostString(); - } - - private String getContactPointPort() { - return String.valueOf(cassandraContainer.getContactPoint().getPort()); - } - - private Duration getTimeToLive() { - return Duration.ofSeconds(12000); - } - - private String getTimeToLiveString() { - return "PT1M"; - } - -} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/test/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryPropertiesTest.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/test/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryPropertiesTest.java deleted file mode 100644 index a527badd5d9..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cassandra/src/test/java/org/springframework/ai/model/chat/memory/repository/cassandra/autoconfigure/CassandraChatMemoryRepositoryPropertiesTest.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2023-2024 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.cassandra.autoconfigure; - -import java.time.Duration; - -import org.junit.jupiter.api.Test; - -import org.springframework.ai.chat.memory.repository.cassandra.CassandraChatMemoryRepositoryConfig; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * @author Mick Semb Wever - * @author Jihoon Kim - * @since 1.0.0 - */ -class CassandraChatMemoryRepositoryPropertiesTest { - - @Test - void defaultValues() { - var props = new CassandraChatMemoryRepositoryProperties(); - assertThat(props.getKeyspace()).isEqualTo(CassandraChatMemoryRepositoryConfig.DEFAULT_KEYSPACE_NAME); - assertThat(props.getTable()).isEqualTo(CassandraChatMemoryRepositoryConfig.DEFAULT_TABLE_NAME); - assertThat(props.getMessagesColumn()) - .isEqualTo(CassandraChatMemoryRepositoryConfig.DEFAULT_MESSAGES_COLUMN_NAME); - - assertThat(props.getTimeToLive()).isNull(); - assertThat(props.isInitializeSchema()).isTrue(); - } - - @Test - void customValues() { - var props = new CassandraChatMemoryRepositoryProperties(); - props.setKeyspace("my_keyspace"); - props.setTable("my_table"); - props.setMessagesColumn("my_messages_column"); - props.setTimeToLive(Duration.ofDays(1)); - props.setInitializeSchema(false); - - assertThat(props.getKeyspace()).isEqualTo("my_keyspace"); - assertThat(props.getTable()).isEqualTo("my_table"); - assertThat(props.getMessagesColumn()).isEqualTo("my_messages_column"); - assertThat(props.getTimeToLive()).isEqualTo(Duration.ofDays(1)); - assertThat(props.isInitializeSchema()).isFalse(); - } - -} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cosmos-db/src/main/java/org/springframework/ai/model/chat/memory/repository/cosmosdb/autoconfigure/CosmosDBChatMemoryRepositoryAutoConfiguration.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cosmos-db/src/main/java/org/springframework/ai/model/chat/memory/repository/cosmosdb/autoconfigure/CosmosDBChatMemoryRepositoryAutoConfiguration.java deleted file mode 100644 index 1a3ca9e9711..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cosmos-db/src/main/java/org/springframework/ai/model/chat/memory/repository/cosmosdb/autoconfigure/CosmosDBChatMemoryRepositoryAutoConfiguration.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2023-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.cosmosdb.autoconfigure; - -import com.azure.cosmos.CosmosAsyncClient; -import com.azure.cosmos.CosmosClientBuilder; -import com.azure.identity.DefaultAzureCredentialBuilder; - -import org.springframework.ai.chat.memory.repository.cosmosdb.CosmosDBChatMemoryRepository; -import org.springframework.ai.chat.memory.repository.cosmosdb.CosmosDBChatMemoryRepositoryConfig; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; - -/** - * {@link AutoConfiguration Auto-configuration} for {@link CosmosDBChatMemoryRepository}. - * - * @author Theo van Kraay - * @since 1.0.0 - */ -@AutoConfiguration -@ConditionalOnClass({ CosmosDBChatMemoryRepository.class, CosmosAsyncClient.class }) -@EnableConfigurationProperties(CosmosDBChatMemoryRepositoryProperties.class) -public class CosmosDBChatMemoryRepositoryAutoConfiguration { - - private final String agentSuffix = "SpringAI-CDBNoSQL-ChatMemoryRepository"; - - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty(prefix = "spring.ai.chat.memory.repository.cosmosdb", name = "endpoint") - public CosmosAsyncClient cosmosClient(CosmosDBChatMemoryRepositoryProperties properties) { - if (properties.getEndpoint() == null || properties.getEndpoint().isEmpty()) { - throw new IllegalArgumentException( - "Cosmos DB endpoint must be provided via spring.ai.chat.memory.repository.cosmosdb.endpoint property"); - } - - String mode = properties.getConnectionMode(); - if (mode == null) { - properties.setConnectionMode("gateway"); - } - else if (!mode.equals("direct") && !mode.equals("gateway")) { - throw new IllegalArgumentException("Connection mode must be either 'direct' or 'gateway'"); - } - - CosmosClientBuilder builder = new CosmosClientBuilder().endpoint(properties.getEndpoint()) - .userAgentSuffix(this.agentSuffix); - - if (properties.getKey() == null || properties.getKey().isEmpty()) { - builder.credential(new DefaultAzureCredentialBuilder().build()); - } - else { - builder.key(properties.getKey()); - } - - return ("direct".equals(properties.getConnectionMode()) ? builder.directMode() : builder.gatewayMode()) - .buildAsyncClient(); - } - - @Bean - @ConditionalOnMissingBean - public CosmosDBChatMemoryRepositoryConfig cosmosDBChatMemoryRepositoryConfig( - CosmosDBChatMemoryRepositoryProperties properties, CosmosAsyncClient cosmosAsyncClient) { - - return CosmosDBChatMemoryRepositoryConfig.builder() - .withCosmosClient(cosmosAsyncClient) - .withDatabaseName(properties.getDatabaseName()) - .withContainerName(properties.getContainerName()) - .withPartitionKeyPath(properties.getPartitionKeyPath()) - .build(); - } - - @Bean - @ConditionalOnMissingBean - public CosmosDBChatMemoryRepository cosmosDBChatMemoryRepository(CosmosDBChatMemoryRepositoryConfig config) { - return CosmosDBChatMemoryRepository.create(config); - } - -} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cosmos-db/src/test/java/org/springframework/ai/model/chat/memory/repository/cosmosdb/autoconfigure/CosmosDBChatMemoryRepositoryAutoConfigurationIT.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cosmos-db/src/test/java/org/springframework/ai/model/chat/memory/repository/cosmosdb/autoconfigure/CosmosDBChatMemoryRepositoryAutoConfigurationIT.java deleted file mode 100644 index 5b3d6f1d9da..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cosmos-db/src/test/java/org/springframework/ai/model/chat/memory/repository/cosmosdb/autoconfigure/CosmosDBChatMemoryRepositoryAutoConfigurationIT.java +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright 2023-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.cosmosdb.autoconfigure; - -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; - -import org.springframework.ai.chat.memory.repository.cosmosdb.CosmosDBChatMemoryRepository; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.MessageType; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Integration tests for {@link CosmosDBChatMemoryRepositoryAutoConfiguration}. - * - * @author Theo van Kraay - * @since 1.0.0 - */ -@EnabledIfEnvironmentVariable(named = "AZURE_COSMOSDB_ENDPOINT", matches = ".+") -class CosmosDBChatMemoryRepositoryAutoConfigurationIT { - - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(CosmosDBChatMemoryRepositoryAutoConfiguration.class)) - .withPropertyValues( - "spring.ai.chat.memory.repository.cosmosdb.endpoint=" + System.getenv("AZURE_COSMOSDB_ENDPOINT")) - .withPropertyValues("spring.ai.chat.memory.repository.cosmosdb.database-name=test-database") - .withPropertyValues("spring.ai.chat.memory.repository.cosmosdb.container-name=autoconfig-test-container"); - - @Test - void addAndGet() { - this.contextRunner.run(context -> { - CosmosDBChatMemoryRepository memory = context.getBean(CosmosDBChatMemoryRepository.class); - - String conversationId = UUID.randomUUID().toString(); - assertThat(memory.findByConversationId(conversationId)).isEmpty(); - - memory.saveAll(conversationId, List.of(new UserMessage("test question"))); - - assertThat(memory.findByConversationId(conversationId)).hasSize(1); - assertThat(memory.findByConversationId(conversationId).get(0).getMessageType()).isEqualTo(MessageType.USER); - assertThat(memory.findByConversationId(conversationId).get(0).getText()).isEqualTo("test question"); - - memory.deleteByConversationId(conversationId); - assertThat(memory.findByConversationId(conversationId)).isEmpty(); - - memory.saveAll(conversationId, - List.of(new UserMessage("test question"), new AssistantMessage("test answer"))); - - assertThat(memory.findByConversationId(conversationId)).hasSize(2); - assertThat(memory.findByConversationId(conversationId).get(0).getMessageType()).isEqualTo(MessageType.USER); - assertThat(memory.findByConversationId(conversationId).get(0).getText()).isEqualTo("test question"); - assertThat(memory.findByConversationId(conversationId).get(1).getMessageType()) - .isEqualTo(MessageType.ASSISTANT); - assertThat(memory.findByConversationId(conversationId).get(1).getText()).isEqualTo("test answer"); - }); - } - - @Test - void propertiesConfiguration() { - this.contextRunner - .withPropertyValues( - "spring.ai.chat.memory.repository.cosmosdb.endpoint=" + System.getenv("AZURE_COSMOSDB_ENDPOINT")) - .withPropertyValues("spring.ai.chat.memory.repository.cosmosdb.database-name=test-database") - .withPropertyValues("spring.ai.chat.memory.repository.cosmosdb.container-name=custom-testcontainer") - .withPropertyValues("spring.ai.chat.memory.repository.cosmosdb.partition-key-path=/customPartitionKey") - .run(context -> { - CosmosDBChatMemoryRepositoryProperties properties = context - .getBean(CosmosDBChatMemoryRepositoryProperties.class); - assertThat(properties.getEndpoint()).isEqualTo(System.getenv("AZURE_COSMOSDB_ENDPOINT")); - assertThat(properties.getDatabaseName()).isEqualTo("test-database"); - assertThat(properties.getContainerName()).isEqualTo("custom-testcontainer"); - assertThat(properties.getPartitionKeyPath()).isEqualTo("/customPartitionKey"); - }); - } - - @Test - void findConversationIds() { - this.contextRunner.run(context -> { - CosmosDBChatMemoryRepository memory = context.getBean(CosmosDBChatMemoryRepository.class); - - String conversationId1 = UUID.randomUUID().toString(); - String conversationId2 = UUID.randomUUID().toString(); - - memory.saveAll(conversationId1, List.of(new UserMessage("test question 1"))); - memory.saveAll(conversationId2, List.of(new UserMessage("test question 2"))); - - List conversationIds = memory.findConversationIds(); - assertThat(conversationIds).contains(conversationId1, conversationId2); - }); - } - -} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cosmos-db/src/test/java/org/springframework/ai/model/chat/memory/repository/cosmosdb/autoconfigure/CosmosDBChatMemoryRepositoryPropertiesTest.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cosmos-db/src/test/java/org/springframework/ai/model/chat/memory/repository/cosmosdb/autoconfigure/CosmosDBChatMemoryRepositoryPropertiesTest.java deleted file mode 100644 index 03c972f3e3a..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-cosmos-db/src/test/java/org/springframework/ai/model/chat/memory/repository/cosmosdb/autoconfigure/CosmosDBChatMemoryRepositoryPropertiesTest.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2023-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.cosmosdb.autoconfigure; - -import org.junit.jupiter.api.Test; - -import org.springframework.ai.chat.memory.repository.cosmosdb.CosmosDBChatMemoryRepositoryConfig; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.context.annotation.Configuration; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Unit tests for {@link CosmosDBChatMemoryRepositoryProperties}. - * - * @author Theo van Kraay - * @since 1.0.0 - */ -class CosmosDBChatMemoryRepositoryPropertiesTest { - - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withUserConfiguration(TestConfiguration.class); - - @Test - void defaultProperties() { - this.contextRunner.run(context -> { - CosmosDBChatMemoryRepositoryProperties properties = context - .getBean(CosmosDBChatMemoryRepositoryProperties.class); - assertThat(properties.getDatabaseName()) - .isEqualTo(CosmosDBChatMemoryRepositoryConfig.DEFAULT_DATABASE_NAME); - assertThat(properties.getContainerName()) - .isEqualTo(CosmosDBChatMemoryRepositoryConfig.DEFAULT_CONTAINER_NAME); - assertThat(properties.getPartitionKeyPath()) - .isEqualTo(CosmosDBChatMemoryRepositoryConfig.DEFAULT_PARTITION_KEY_PATH); - }); - } - - @Test - void customProperties() { - this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.cosmosdb.database-name=custom-db") - .withPropertyValues("spring.ai.chat.memory.repository.cosmosdb.container-name=custom-container") - .withPropertyValues("spring.ai.chat.memory.repository.cosmosdb.partition-key-path=/custom-partition-key") - .run(context -> { - CosmosDBChatMemoryRepositoryProperties properties = context - .getBean(CosmosDBChatMemoryRepositoryProperties.class); - assertThat(properties.getDatabaseName()).isEqualTo("custom-db"); - assertThat(properties.getContainerName()).isEqualTo("custom-container"); - assertThat(properties.getPartitionKeyPath()).isEqualTo("/custom-partition-key"); - }); - } - - @Configuration - @EnableConfigurationProperties(CosmosDBChatMemoryRepositoryProperties.class) - static class TestConfiguration { - - } - -} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositoryHsqldbAutoConfigurationIT.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositoryHsqldbAutoConfigurationIT.java deleted file mode 100644 index abfd6927a46..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositoryHsqldbAutoConfigurationIT.java +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright 2024-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure; - -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; - -import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.boot.autoconfigure.ImportAutoConfiguration; -import org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; - -@ExtendWith(SpringExtension.class) -@SpringBootTest(classes = JdbcChatMemoryRepositoryHsqldbAutoConfigurationIT.TestConfig.class, - properties = { "spring.datasource.url=jdbc:hsqldb:mem:chat_memory_auto_configuration_test;DB_CLOSE_DELAY=-1", - "spring.datasource.username=sa", "spring.datasource.password=", - "spring.datasource.driver-class-name=org.hsqldb.jdbcDriver", - "spring.ai.chat.memory.repository.jdbc.initialize-schema=always", "spring.sql.init.mode=always", - "spring.jpa.hibernate.ddl-auto=none", "spring.jpa.defer-datasource-initialization=true", - "spring.sql.init.continue-on-error=true", "spring.sql.init.schema-locations=classpath:schema.sql", - "logging.level.org.springframework.jdbc=DEBUG", - "logging.level.org.springframework.boot.sql.init=DEBUG" }) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@ImportAutoConfiguration({ org.springframework.ai.model.chat.memory.autoconfigure.ChatMemoryAutoConfiguration.class, - JdbcChatMemoryRepositoryAutoConfiguration.class, - org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration.class, - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class, - SqlInitializationAutoConfiguration.class }) -public class JdbcChatMemoryRepositoryHsqldbAutoConfigurationIT { - - @Autowired - private ApplicationContext context; - - @Autowired - private JdbcTemplate jdbcTemplate; - - /** - * can't get the automatic loading of the schema with boot to work. - */ - @BeforeEach - public void setUp() { - // Explicitly initialize the schema - try { - System.out.println("Explicitly initializing schema..."); - - // Debug: Print current schemas and tables - try { - List schemas = this.jdbcTemplate - .queryForList("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA", String.class); - System.out.println("Available schemas: " + schemas); - - List tables = this.jdbcTemplate - .queryForList("SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES", String.class); - System.out.println("Available tables: " + tables); - } - catch (Exception e) { - System.out.println("Error listing schemas/tables: " + e.getMessage()); - } - - // Try a more direct approach with explicit SQL statements - try { - // Drop the table first if it exists to avoid any conflicts - this.jdbcTemplate.execute("DROP TABLE SPRING_AI_CHAT_MEMORY IF EXISTS"); - System.out.println("Dropped existing table if it existed"); - - // Create the table with a simplified schema - this.jdbcTemplate.execute("CREATE TABLE SPRING_AI_CHAT_MEMORY (" - + "conversation_id VARCHAR(36) NOT NULL, " + "content LONGVARCHAR NOT NULL, " - + "type VARCHAR(10) NOT NULL, " + "timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL)"); - System.out.println("Created table with simplified schema"); - - // Create index - this.jdbcTemplate.execute( - "CREATE INDEX SPRING_AI_CHAT_MEMORY_IDX ON SPRING_AI_CHAT_MEMORY(conversation_id, timestamp DESC)"); - System.out.println("Created index"); - - // Verify table was created - boolean tableExists = this.jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'SPRING_AI_CHAT_MEMORY'", - Integer.class) > 0; - System.out.println("Table SPRING_AI_CHAT_MEMORY exists after creation: " + tableExists); - } - catch (Exception e) { - System.out.println("Error during direct table creation: " + e.getMessage()); - e.printStackTrace(); - } - - System.out.println("Schema initialization completed"); - } - catch (Exception e) { - System.out.println("Error during explicit schema initialization: " + e.getMessage()); - e.printStackTrace(); - } - } - - @Test - public void useAutoConfiguredChatMemoryWithJdbc() { - // Check that the custom schema initializer is present - assertThat(this.context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isTrue(); - - // Debug: List all schema-hsqldb.sql resources on the classpath - try { - java.util.Enumeration resources = Thread.currentThread() - .getContextClassLoader() - .getResources("org/springframework/ai/chat/memory/repository/jdbc/schema-hsqldb.sql"); - System.out.println("--- schema-hsqldb.sql resources found on classpath ---"); - while (resources.hasMoreElements()) { - System.out.println(resources.nextElement()); - } - System.out.println("------------------------------------------------------"); - } - catch (Exception e) { - e.printStackTrace(); - } - - // Verify the table exists by executing a direct query - try { - boolean tableExists = this.jdbcTemplate.queryForObject( - "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'SPRING_AI_CHAT_MEMORY'", - Integer.class) > 0; - System.out.println("Table SPRING_AI_CHAT_MEMORY exists: " + tableExists); - assertThat(tableExists).isTrue(); - } - catch (Exception e) { - System.out.println("Error checking table: " + e.getMessage()); - e.printStackTrace(); - fail("Failed to check if table exists: " + e.getMessage()); - } - - // Now test the ChatMemory functionality - assertThat(this.context.getBean(org.springframework.ai.chat.memory.ChatMemory.class)).isNotNull(); - assertThat(this.context.getBean(JdbcChatMemoryRepository.class)).isNotNull(); - - var chatMemory = this.context.getBean(org.springframework.ai.chat.memory.ChatMemory.class); - var conversationId = java.util.UUID.randomUUID().toString(); - var userMessage = new UserMessage("Message from the user"); - - chatMemory.add(conversationId, userMessage); - assertThat(chatMemory.get(conversationId)).hasSize(1); - assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage)); - - var assistantMessage = new AssistantMessage("Message from the assistant"); - chatMemory.add(conversationId, List.of(assistantMessage)); - assertThat(chatMemory.get(conversationId)).hasSize(2); - assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage, assistantMessage)); - - chatMemory.clear(conversationId); - assertThat(chatMemory.get(conversationId)).isEmpty(); - - var multipleMessages = List.of(new UserMessage("Message from the user 1"), - new AssistantMessage("Message from the assistant 1")); - chatMemory.add(conversationId, multipleMessages); - assertThat(chatMemory.get(conversationId)).hasSize(multipleMessages.size()); - assertThat(chatMemory.get(conversationId)).isEqualTo(multipleMessages); - } - - @SpringBootConfiguration - static class TestConfig { - - } - -} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositoryPostgresqlAutoConfigurationIT.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositoryPostgresqlAutoConfigurationIT.java deleted file mode 100644 index c6bde91fff4..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositoryPostgresqlAutoConfigurationIT.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright 2024-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure; - -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.Test; - -import org.springframework.ai.chat.memory.ChatMemory; -import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.model.chat.memory.autoconfigure.ChatMemoryAutoConfiguration; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * @author Jonathan Leijendekker - * @author Thomas Vitale - * @author Linar Abzaltdinov - * @author Yanming Zhou - */ -class JdbcChatMemoryRepositoryPostgresqlAutoConfigurationIT { - - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(JdbcChatMemoryRepositoryAutoConfiguration.class, - JdbcTemplateAutoConfiguration.class, DataSourceAutoConfiguration.class)) - .withPropertyValues("spring.datasource.url=jdbc:tc:postgresql:17:///"); - - @Test - void jdbcChatMemoryScriptDatabaseInitializer_shouldBeLoaded() { - this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=always") - .run(context -> assertThat(context).hasBean("jdbcChatMemoryScriptDatabaseInitializer")); - } - - @Test - void jdbcChatMemoryScriptDatabaseInitializer_shouldNotRunSchemaInit() { - // CHECKSTYLE:OFF - this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=never") - .run(context -> { - assertThat(context).doesNotHaveBean("jdbcChatMemoryScriptDatabaseInitializer"); - // Optionally, check that the schema is not initialized (could check table - // absence if needed) - }); - // CHECKSTYLE:ON - } - - @Test - void initializeSchemaEmbeddedDefault() { - this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=embedded") - .run(context -> assertThat(context).hasBean("jdbcChatMemoryScriptDatabaseInitializer")); - } - - @Test - void useAutoConfiguredJdbcChatMemoryRepository() { - this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=always") - .run(context -> { - var chatMemoryRepository = context.getBean(JdbcChatMemoryRepository.class); - var conversationId = UUID.randomUUID().toString(); - var userMessage = new UserMessage("Message from the user"); - - chatMemoryRepository.saveAll(conversationId, List.of(userMessage)); - - assertThat(chatMemoryRepository.findByConversationId(conversationId)).hasSize(1); - assertThat(chatMemoryRepository.findByConversationId(conversationId)).isEqualTo(List.of(userMessage)); - - chatMemoryRepository.deleteByConversationId(conversationId); - - assertThat(chatMemoryRepository.findByConversationId(conversationId)).isEmpty(); - - var multipleMessages = List.of(new UserMessage("Message from the user 1"), - new AssistantMessage("Message from the assistant 1")); - - chatMemoryRepository.saveAll(conversationId, multipleMessages); - - assertThat(chatMemoryRepository.findByConversationId(conversationId)).hasSize(multipleMessages.size()); - assertThat(chatMemoryRepository.findByConversationId(conversationId)).isEqualTo(multipleMessages); - }); - } - - @Test - void useAutoConfiguredChatMemoryWithJdbc() { - this.contextRunner.withConfiguration(AutoConfigurations.of(ChatMemoryAutoConfiguration.class)) - .withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=always") - .run(context -> { - assertThat(context).hasSingleBean(ChatMemory.class); - assertThat(context).hasSingleBean(JdbcChatMemoryRepository.class); - - var chatMemory = context.getBean(ChatMemory.class); - var conversationId = UUID.randomUUID().toString(); - var userMessage = new UserMessage("Message from the user"); - - chatMemory.add(conversationId, userMessage); - - assertThat(chatMemory.get(conversationId)).hasSize(1); - assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage)); - - var assistantMessage = new AssistantMessage("Message from the assistant"); - - chatMemory.add(conversationId, List.of(assistantMessage)); - - assertThat(chatMemory.get(conversationId)).hasSize(2); - assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage, assistantMessage)); - - chatMemory.clear(conversationId); - - assertThat(chatMemory.get(conversationId)).isEmpty(); - - var multipleMessages = List.of(new UserMessage("Message from the user 1"), - new AssistantMessage("Message from the assistant 1")); - - chatMemory.add(conversationId, multipleMessages); - - assertThat(chatMemory.get(conversationId)).hasSize(multipleMessages.size()); - assertThat(chatMemory.get(conversationId)).isEqualTo(multipleMessages); - }); - } - -} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositorySchemaInitializerPostgresqlTests.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositorySchemaInitializerPostgresqlTests.java deleted file mode 100644 index 03542a87d98..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositorySchemaInitializerPostgresqlTests.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2024-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure; - -import javax.sql.DataSource; - -import org.junit.jupiter.api.Test; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * @author Jonathan Leijendekker - */ -@Testcontainers -class JdbcChatMemoryRepositorySchemaInitializerPostgresqlTests { - - static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("postgres:17"); - - @Container - @SuppressWarnings("resource") - static PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>(DEFAULT_IMAGE_NAME) - .withDatabaseName("chat_memory_initializer_test") - .withUsername("postgres") - .withPassword("postgres"); - - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(JdbcChatMemoryRepositoryAutoConfiguration.class, - JdbcTemplateAutoConfiguration.class, DataSourceAutoConfiguration.class)) - .withPropertyValues(String.format("spring.datasource.url=%s", postgresContainer.getJdbcUrl()), - String.format("spring.datasource.username=%s", postgresContainer.getUsername()), - String.format("spring.datasource.password=%s", postgresContainer.getPassword())); - - @Test - void getSettings_shouldHaveSchemaLocations() { - this.contextRunner.run(context -> { - var dataSource = context.getBean(DataSource.class); - // Use new signature: requires JdbcChatMemoryRepositoryProperties - var settings = JdbcChatMemoryRepositorySchemaInitializer.getSettings(dataSource, - new JdbcChatMemoryRepositoryProperties()); - - assertThat(settings.getSchemaLocations()) - .containsOnly("classpath:org/springframework/ai/chat/memory/repository/jdbc/schema-postgresql.sql"); - }); - } - -} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositorySqlServerAutoConfigurationIT.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositorySqlServerAutoConfigurationIT.java deleted file mode 100644 index a3bf69410ac..00000000000 --- a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc/src/test/java/org/springframework/ai/model/chat/memory/repository/jdbc/autoconfigure/JdbcChatMemoryRepositorySqlServerAutoConfigurationIT.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2024-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure; - -import java.time.Duration; -import java.util.List; -import java.util.UUID; - -import org.junit.jupiter.api.Test; -import org.testcontainers.containers.MSSQLServerContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -import org.springframework.ai.chat.memory.ChatMemory; -import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.model.chat.memory.autoconfigure.ChatMemoryAutoConfiguration; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; - -import static org.assertj.core.api.Assertions.assertThat; - -/* - * Integration test for SQL Server using Testcontainers, following the same structure as the PostgreSQL test. - */ -@Testcontainers -class JdbcChatMemoryRepositorySqlServerAutoConfigurationIT { - - static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName - .parse("mcr.microsoft.com/mssql/server:2022-latest"); - - @Container - @SuppressWarnings("resource") - static MSSQLServerContainer mssqlContainer = new MSSQLServerContainer<>(DEFAULT_IMAGE_NAME).acceptLicense() - .withEnv("MSSQL_DATABASE", "chat_memory_auto_configuration_test") - .withPassword("Strong!NotR34LLyPassword") - .withUrlParam("loginTimeout", "60") // Give more time for the login - .withUrlParam("connectRetryCount", "10") // Retry 10 times - .withUrlParam("connectRetryInterval", "10") - .withStartupTimeout(Duration.ofSeconds(60)); - - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(JdbcChatMemoryRepositoryAutoConfiguration.class, - JdbcTemplateAutoConfiguration.class, DataSourceAutoConfiguration.class)) - .withPropertyValues(String.format("spring.datasource.url=%s", mssqlContainer.getJdbcUrl()), - String.format("spring.datasource.username=%s", mssqlContainer.getUsername()), - String.format("spring.datasource.password=%s", mssqlContainer.getPassword())); - - @Test - void jdbcChatMemoryScriptDatabaseInitializer_shouldBeLoaded() { - this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=always") - .run(context -> assertThat(context).hasBean("jdbcChatMemoryScriptDatabaseInitializer")); - } - - @Test - void jdbcChatMemoryScriptDatabaseInitializer_shouldNotRunSchemaInit() { - this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=never") - .run(context -> assertThat(context).doesNotHaveBean("jdbcChatMemoryScriptDatabaseInitializer")); - } - - @Test - void initializeSchemaEmbeddedDefault() { - this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=embedded") - .run(context -> assertThat(context).hasBean("jdbcChatMemoryScriptDatabaseInitializer")); - } - - @Test - void useAutoConfiguredChatMemoryWithJdbc() { - this.contextRunner.withConfiguration(AutoConfigurations.of(ChatMemoryAutoConfiguration.class)) - .withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=always") - .run(context -> { - assertThat(context).hasSingleBean(ChatMemory.class); - assertThat(context).hasSingleBean(JdbcChatMemoryRepository.class); - - var chatMemory = context.getBean(ChatMemory.class); - var conversationId = UUID.randomUUID().toString(); - var userMessage = new UserMessage("Message from the user"); - - chatMemory.add(conversationId, userMessage); - - assertThat(chatMemory.get(conversationId)).hasSize(1); - assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage)); - - var assistantMessage = new AssistantMessage("Message from the assistant"); - - chatMemory.add(conversationId, List.of(assistantMessage)); - - assertThat(chatMemory.get(conversationId)).hasSize(2); - assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage, assistantMessage)); - - chatMemory.clear(conversationId); - - assertThat(chatMemory.get(conversationId)).isEmpty(); - - var multipleMessages = List.of(new UserMessage("Message from the user 1"), - new AssistantMessage("Message from the assistant 1")); - - chatMemory.add(conversationId, multipleMessages); - - assertThat(chatMemory.get(conversationId)).hasSize(multipleMessages.size()); - assertThat(chatMemory.get(conversationId)).isEqualTo(multipleMessages); - }); - } - -} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/api/VideoApi.java b/spring-ai-model/src/main/java/org/springframework/ai/video/api/VideoApi.java new file mode 100644 index 00000000000..77745aa9b55 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/api/VideoApi.java @@ -0,0 +1,149 @@ +// +// Source code recreated from a .class file by IntelliJ IDEA +// (powered by FernFlower decompiler) +// + +package org.springframework.ai.video.api; + +import com.springai.springaivideoextension.enhanced.model.response.VideoResult; +import com.springai.springaivideoextension.enhanced.option.VideoOptions; +import com.springai.springaivideoextension.enhanced.trimer.response.VideoScanResponse; +import org.springframework.ai.model.ApiKey; +import org.springframework.ai.model.NoopApiKey; +import org.springframework.ai.model.SimpleApiKey; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClient; + +import java.util.Map; + +public class VideoApi { + private final RestClient restClient; + // 改动点四:将原先的imagesPath改为videoPath,作为后缀请求地址传入 + private final String videoPath; + private final String videoStatusPath; + + public VideoApi(String baseUrl, ApiKey apiKey, MultiValueMap headers, String videoPath, String videoStatusPath, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + this.restClient = restClientBuilder.baseUrl(baseUrl).defaultHeaders((h) -> { + if (!(apiKey instanceof NoopApiKey)) { + h.setBearerAuth(apiKey.getValue()); + } + + h.setContentType(MediaType.APPLICATION_JSON); + h.addAll(headers); + }).defaultStatusHandler(responseErrorHandler).build(); + this.videoPath = videoPath; + this.videoStatusPath = videoStatusPath; + } + + /** + * 新改动:将返回参数类型改为VideoResult,方便接收返回参数,删除原来的Response的Record类 + * @param videoOptions 自定义的VideoOptions,该接口将会继承ModelOptions + * @return 返回参数 + */ + public ResponseEntity createVideo(VideoOptions videoOptions) { + Assert.notNull(videoOptions, "Video request cannot be null."); + Assert.hasLength(videoOptions.getPrompt(), "Prompt cannot be empty."); + + return this.restClient.post() + .uri(this.videoPath) + .body(videoOptions) + .retrieve() + .toEntity(VideoResult.class); + } + + /** + * 改动点五:新增一个方法,用于处理视频扫描结果 + * @param requestId 扫描结果标识 + * @return 扫描结果 + */ + public ResponseEntity createVideo(String requestId) { + Assert.notNull(requestId, "Video request cannot be null."); + + return this.restClient.post() + // 注意,这里更换为另一个后缀地址,如果需要,请自行修改,后续会提供完整的压缩包 + .uri(this.videoStatusPath) + .body(Map.of("requestId", requestId)) + .retrieve() + .toEntity(VideoScanResponse.class); + } + + public static Builder builder() { + return new Builder(); + } + + + public static class Builder { + private String baseUrl = "https://api.openai.com"; + private ApiKey apiKey; + private MultiValueMap headers = new LinkedMultiValueMap(); + private RestClient.Builder restClientBuilder = RestClient.builder(); + private ResponseErrorHandler responseErrorHandler; + private String videoPath; + private String videoStatusPath; + + public Builder() { + this.responseErrorHandler = RetryUtils.DEFAULT_RESPONSE_ERROR_HANDLER; + this.videoPath = "v1/video/submit"; + this.videoStatusPath = "v1/video/status"; + } + + public Builder baseUrl(String baseUrl) { + Assert.hasText(baseUrl, "baseUrl cannot be null or empty"); + this.baseUrl = baseUrl; + return this; + } + + public Builder videoPath(String videoPath) { + Assert.hasText(videoPath, "videoPath cannot be null or empty"); + this.videoPath = videoPath; + return this; + } + + public Builder videoStatusPath(String videoStatusPath) { + Assert.hasText(videoStatusPath, "videoStatusPath cannot be null or empty"); + this.videoStatusPath = videoStatusPath; + return this; + } + + public Builder apiKey(ApiKey apiKey) { + Assert.notNull(apiKey, "apiKey cannot be null"); + this.apiKey = apiKey; + return this; + } + + public Builder apiKey(String simpleApiKey) { + Assert.notNull(simpleApiKey, "simpleApiKey cannot be null"); + this.apiKey = new SimpleApiKey(simpleApiKey); + return this; + } + + public Builder headers(MultiValueMap headers) { + Assert.notNull(headers, "headers cannot be null"); + this.headers = headers; + return this; + } + + public Builder restClientBuilder(RestClient.Builder restClientBuilder) { + Assert.notNull(restClientBuilder, "restClientBuilder cannot be null"); + this.restClientBuilder = restClientBuilder; + return this; + } + + public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) { + Assert.notNull(responseErrorHandler, "responseErrorHandler cannot be null"); + this.responseErrorHandler = responseErrorHandler; + return this; + } + + public VideoApi build() { + Assert.notNull(this.apiKey, "apiKey must be set"); + return new VideoApi(this.baseUrl, this.apiKey, this.headers, this.videoPath, this.videoStatusPath, this.restClientBuilder, this.responseErrorHandler); + } + } +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/client/VideoClient.java b/spring-ai-model/src/main/java/org/springframework/ai/video/client/VideoClient.java new file mode 100644 index 00000000000..00b99b4374e --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/client/VideoClient.java @@ -0,0 +1,187 @@ +package org.springframework.ai.video.client; + +import com.springai.springaivideoextension.enhanced.model.VideoModel; +import com.springai.springaivideoextension.enhanced.model.request.VideoPrompt; +import com.springai.springaivideoextension.enhanced.model.response.VideoResponse; +import com.springai.springaivideoextension.enhanced.option.VideoOptions; +import com.springai.springaivideoextension.enhanced.option.impl.VideoOptionsImpl; +import com.springai.springaivideoextension.enhanced.storage.VideoStorage; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; + +/** + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/30 + */ +@Slf4j +public class VideoClient { + + // 视频模型接口,用于调用视频生成服务 + private final VideoModel videoModel; + // 视频存储接口,用于持久化视频生成结果 + private final VideoStorage videoStorage; + + /** + * 构造函数,仅指定视频模型 + * @param videoModel 视频模型实例 + */ + public VideoClient(VideoModel videoModel) { + this.videoModel = videoModel; + this.videoStorage = null; + } + + /** + * 构造函数,指定视频模型和存储接口 + * @param videoModel 视频模型实例 + * @param videoStorage 视频存储实例 + */ + public VideoClient(VideoModel videoModel, VideoStorage videoStorage) { + this.videoModel = videoModel; + this.videoStorage = videoStorage; + } + + /** + * 调用视频模型生成视频,并根据配置决定是否持久化结果 + * @param videoPrompt 视频生成请求参数 + * @return 视频生成响应结果 + */ + private VideoResponse call(VideoPrompt videoPrompt) { + // 调用视频模型生成视频 + VideoResponse videoResponse = this.videoModel.call(videoPrompt); + + // 获取生成结果的输出信息 + String output = videoResponse.getResult().getOutput(); + log.info("视频生成结果: {}", output); + + // 如果未配置存储接口,则直接返回结果 + if (Objects.isNull(this.videoStorage)) { + log.warn("未指定持久化容器,将返回原始结果"); + return videoResponse; + } + + // 持久化视频生成结果 + log.info("开始持久化请求结果"); + boolean save = this.videoStorage.save(output); + if (!save) { + log.warn("持久化失败, 请求id: {}", output); + } + + return videoResponse; + } + + /** + * 创建参数构建器实例 + * @return 参数构建器 + */ + public ParamBuilder param() { + return new ParamBuilder(); + } + + /** + * 参数构建器类,用于构建视频生成请求参数 + */ + public class ParamBuilder { + // 视频生成提示词 + private String prompt; + // 使用的模型名称 + private String model; + // 生成视频的尺寸 + private String imageSize; + // 负面提示词,用于排除不希望出现的内容 + private String negativePrompt; + // 参考图像路径 + private String image; + // 随机种子,用于控制生成的一致性 + private Long seed; + + /** + * 设置视频生成提示词 + * @param prompt 提示词 + * @return 参数构建器实例 + */ + public ParamBuilder prompt(String prompt) { + this.prompt = prompt; + return this; + } + + /** + * 设置使用的模型名称 + * @param model 模型名称 + * @return 参数构建器实例 + */ + public ParamBuilder model(String model) { + this.model = model; + return this; + } + + /** + * 设置生成视频的尺寸 + * @param imageSize 视频尺寸 + * @return 参数构建器实例 + */ + public ParamBuilder imageSize(String imageSize) { + this.imageSize = imageSize; + return this; + } + + /** + * 设置负面提示词 + * @param negativePrompt 负面提示词 + * @return 参数构建器实例 + */ + public ParamBuilder negativePrompt(String negativePrompt) { + this.negativePrompt = negativePrompt; + return this; + } + + /** + * 设置参考图像路径 + * @param image 图像路径 + * @return 参数构建器实例 + */ + public ParamBuilder image(String image) { + this.image = image; + return this; + } + + /** + * 设置随机种子 + * @param seed 随机种子 + * @return 参数构建器实例 + */ + public ParamBuilder seed(Long seed) { + this.seed = seed; + return this; + } + + /** + * 执行视频生成请求 + * @return 视频生成响应结果 + */ + public VideoResponse call() { + // 构建视频选项参数 + VideoOptions options = VideoOptionsImpl.builder() + .prompt(prompt) + .model(model) + .imageSize(imageSize) + .negativePrompt(negativePrompt) + .image(image) + .seed(seed) + .build(); + // 创建视频提示对象 + VideoPrompt videoPrompt = new VideoPrompt(prompt, options); + // 调用视频生成接口 + return VideoClient.this.call(videoPrompt); + } + + /** + * 获取视频生成结果的输出信息 + * @return 输出信息 + */ + public String getOutput() { + return this.call().getResult().getOutput(); + } + } +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/config/EnhancedVideoConfig.java b/spring-ai-model/src/main/java/org/springframework/ai/video/config/EnhancedVideoConfig.java new file mode 100644 index 00000000000..fe27d29b959 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/config/EnhancedVideoConfig.java @@ -0,0 +1,104 @@ +package org.springframework.ai.video.config; + +import com.springai.springaivideoextension.enhanced.api.VideoApi; +import com.springai.springaivideoextension.enhanced.client.VideoClient; +import com.springai.springaivideoextension.enhanced.model.VideoModel; +import com.springai.springaivideoextension.enhanced.model.enums.VideoGenerationModel; +import com.springai.springaivideoextension.enhanced.model.impl.VideoModelImpl; +import com.springai.springaivideoextension.enhanced.option.VideoOptions; +import com.springai.springaivideoextension.enhanced.option.impl.VideoOptionsImpl; +import com.springai.springaivideoextension.enhanced.storage.VideoStorage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 增强版视频服务配置类 + * + * 该配置类负责初始化视频相关的Bean组件,包括视频API客户端和视频存储服务。 + * 通过读取应用配置属性来构建视频API客户端,并提供内存存储的默认实现。 + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/10/2 + */ +@Slf4j +@Configuration +public class EnhancedVideoConfig { + + /** + * OpenAI API密钥,从配置文件中读取 + * 用于视频处理服务的身份验证 + */ + @Value("${spring.ai.openai.api-key}") + private String apiKey; + + /** + * OpenAI API基础URL,从配置文件中读取 + * 用于指定视频处理服务的访问地址 + */ + @Value("${spring.ai.openai.base-url}") + private String baseUrl; + + /** + * 创建视频API客户端Bean + * + * 使用Builder模式构建VideoApi实例,配置了API密钥、基础URL以及 + * 视频提交和状态查询的路径端点。 + * + * @return 配置完成的VideoApi实例 + */ + @Bean + public VideoApi videoApi() { + return VideoApi.builder() + .apiKey(apiKey) + .baseUrl(baseUrl) + .videoPath("/v1/video/submit") + .videoStatusPath("/v1/video/status") + .build(); + } + + + /** + * 创建默认视频选项Bean + * + * 提供视频生成的默认配置,包括提示词和模型选择。 + * 默认模型设置为"Wan-AI/Wan2.2-T2V-A14B"。 + * + * @return 默认视频选项配置 + */ + @Bean + public VideoOptions defaultVideoOptions() { + return VideoOptionsImpl.builder() + .prompt("") + .model(VideoGenerationModel.QWEN_TEXT_TO_VIDEO.getModel()) + .build(); + } + + /** + * 创建视频模型Bean + * + * 结合视频API客户端和默认选项创建视频模型实现。 + * 作为视频处理的核心业务逻辑层。 + * + * @return 配置的视频模型实例 + */ + @Bean + public VideoModel videoModel() { + return new VideoModelImpl(videoApi(), defaultVideoOptions()); + } + + /** + * 创建视频客户端Bean + * + * 通过组合视频模型和存储服务构建视频操作的主要入口点。 + * 提供视频处理的高层接口。 + * + * @return 配置的视频客户端实例 + */ + @Bean + public VideoClient videoClient(VideoStorage videoStorage) { + return new VideoClient(videoModel(), videoStorage); + } +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/model/VideoModel.java b/spring-ai-model/src/main/java/org/springframework/ai/video/model/VideoModel.java new file mode 100644 index 00000000000..ecebcdaeb0b --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/model/VideoModel.java @@ -0,0 +1,15 @@ +package org.springframework.ai.video.model; + +import com.springai.springaivideoextension.enhanced.model.request.VideoPrompt; +import com.springai.springaivideoextension.enhanced.model.response.VideoResponse; +import org.springframework.ai.model.Model; + +/** + * 注意:这里有两个注意点,VideoPrompt、VideoApi.VideoResponse都需要实现ModelRequest、ModelResponse + * 所以这里我们逐步解决 + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/29 + */ +public interface VideoModel extends Model { +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/model/enums/VideoGenerationModel.java b/spring-ai-model/src/main/java/org/springframework/ai/video/model/enums/VideoGenerationModel.java new file mode 100644 index 00000000000..e4474db62cc --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/model/enums/VideoGenerationModel.java @@ -0,0 +1,41 @@ +package org.springframework.ai.video.model.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 视频生成模型枚举类 + * + * 该枚举定义了系统支持的视频生成模型类型,包括文本到视频和图像到视频两种模式。 + * 每个枚举值包含模型标识符和描述信息。 + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/10/2 + */ +@Getter +@RequiredArgsConstructor +public enum VideoGenerationModel { + + /** + * 阿里千问文本生成视频模型 + * 该模型可以根据文本描述生成相应的视频内容 + */ + QWEN_TEXT_TO_VIDEO("Wan-AI/Wan2.2-T2V-A14B", "Qwen文生视频模型"), + + /** + * 阿里千问视频生成视频模型 + * 该模型可以根据输入图像生成视频内容 + */ + QWEN_IMAGE_TO_VIDEO("Wan-AI/Wan2.2-I2V-A14B", "Qwen图生视频模型"); + + /** + * 模型标识符 + */ + private final String model; + + /** + * 模型描述信息 + */ + private final String description; +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/model/impl/VideoModelImpl.java b/spring-ai-model/src/main/java/org/springframework/ai/video/model/impl/VideoModelImpl.java new file mode 100644 index 00000000000..0f046d10de4 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/model/impl/VideoModelImpl.java @@ -0,0 +1,118 @@ +package org.springframework.ai.video.model.impl; + +import com.springai.springaivideoextension.common.util.BeanUtils; +import com.springai.springaivideoextension.enhanced.api.VideoApi; +import com.springai.springaivideoextension.enhanced.model.VideoModel; +import com.springai.springaivideoextension.enhanced.model.request.VideoPrompt; +import com.springai.springaivideoextension.enhanced.model.response.VideoResponse; +import com.springai.springaivideoextension.enhanced.model.response.VideoResult; +import com.springai.springaivideoextension.enhanced.option.VideoOptions; +import com.springai.springaivideoextension.enhanced.option.impl.VideoOptionsImpl; +import io.micrometer.observation.ObservationRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.util.Assert; + +import java.util.Collections; + +/** + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/29 + */ +public class VideoModelImpl implements VideoModel { + + private static final Logger logger = LoggerFactory.getLogger(VideoModelImpl.class); + + + // 默认配置选项 + private final VideoOptions defaultOptions; + // 视频API接口 + private final VideoApi videoApi; + // 重试模板,用于处理请求失败时的重试逻辑 + private final RetryTemplate retryTemplate; + // 监控注册表,用于收集和报告观察数据 + private final ObservationRegistry observationRegistry; + + /** + * 构造函数,使用默认配置选项、重试模板和监控注册表 + * @param videoApi 视频API接口 + */ + public VideoModelImpl(VideoApi videoApi) { + this(videoApi, VideoOptionsImpl.builder().build(), RetryUtils.DEFAULT_RETRY_TEMPLATE, ObservationRegistry.NOOP); + } + + /** + * 构造函数,使用指定的默认配置选项 + * @param videoApi 视频API接口 + * @param defaultOptions 默认配置选项 + */ + public VideoModelImpl(VideoApi videoApi, VideoOptions defaultOptions) { + this(videoApi, defaultOptions, RetryUtils.DEFAULT_RETRY_TEMPLATE, ObservationRegistry.NOOP); + } + + /** + * 构造函数,使用指定的配置选项和重试模板 + * @param videoApi 视频API接口 + * @param options 配置选项 + * @param retryTemplate 重试模板 + */ + public VideoModelImpl(VideoApi videoApi, VideoOptions options, RetryTemplate retryTemplate) { + this(videoApi, options, retryTemplate, ObservationRegistry.NOOP); + } + + /** + * 完整构造函数,允许自定义所有依赖项 + * @param videoApi 视频API接口 + * @param options 配置选项 + * @param retryTemplate 重试模板 + * @param observationRegistry 监控注册表 + */ + public VideoModelImpl(VideoApi videoApi, VideoOptions options, RetryTemplate retryTemplate, ObservationRegistry observationRegistry) { + Assert.notNull(videoApi, "VideoApi must not be null"); + Assert.notNull(options, "options must not be null"); + Assert.notNull(retryTemplate, "retryTemplate must not be null"); + this.videoApi = videoApi; + this.defaultOptions = options; + this.retryTemplate = retryTemplate; + this.observationRegistry = observationRegistry; + } + + /** + * 调用视频API生成视频 + * @param videoPrompt 视频提示信息 + * @return 视频响应结果 + */ + @Override + public VideoResponse call(VideoPrompt videoPrompt) { + // 获取视频提示中的配置选项 + VideoOptions options = (VideoOptions) videoPrompt.getOptions(); + // 优先使用videoPrompt中的prompt,否则使用options中的prompt + String prompt = BeanUtils.nullThenChooseOther(videoPrompt.getPrompt(), options.getPrompt(), String.class); + + // 构建最终的视频配置选项,按照优先级选择:videoPrompt > options > defaultOptions + VideoOptions videoOptions = VideoOptionsImpl.builder() + .prompt(BeanUtils.nullThenChooseOther(prompt, defaultOptions.getPrompt(), String.class)) + .model(BeanUtils.nullThenChooseOther(options.getModel(), defaultOptions.getModel(), String.class)) + .imageSize(BeanUtils.nullThenChooseOther(options.getImageSize(), defaultOptions.getImageSize(), String.class)) + .negativePrompt(BeanUtils.nullThenChooseOther(options.getNegativePrompt(), defaultOptions.getNegativePrompt(), String.class)) + .image(BeanUtils.nullThenChooseOther(options.getImage(), defaultOptions.getImage(), String.class)) + .seed(BeanUtils.nullThenChooseOther(options.getSeed(), defaultOptions.getSeed(), Long.class)) + .build(); + + // 使用重试模板执行视频创建请求 + ResponseEntity resultResponseEntity = retryTemplate.execute(context -> videoApi.createVideo(videoOptions)); + + // 检查响应状态并返回相应结果 + if (resultResponseEntity.getStatusCode().is2xxSuccessful()) { + VideoResult videoResult = resultResponseEntity.getBody(); + return new VideoResponse(videoResult); + } else { + logger.error("Error creating video: {}", resultResponseEntity.getStatusCode()); + return new VideoResponse(Collections.emptyList()); + } + } +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/model/request/VideoPrompt.java b/spring-ai-model/src/main/java/org/springframework/ai/video/model/request/VideoPrompt.java new file mode 100644 index 00000000000..951b5b7586c --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/model/request/VideoPrompt.java @@ -0,0 +1,83 @@ +package org.springframework.ai.video.model.request; + +import com.springai.springaivideoextension.enhanced.option.VideoOptions; +import org.springframework.ai.model.ModelOptions; +import org.springframework.ai.model.ModelRequest; +import org.springframework.util.Assert; + +import java.util.List; + +/** + * 视频生成请求提示类 + * 用于封装视频生成所需的提示信息和配置选项 + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/29 + */ +public class VideoPrompt implements ModelRequest> { + + /** + * 提示词列表,用于指导视频生成内容 + */ + private List prompts; + + /** + * 视频生成选项配置 + */ + private VideoOptions videoOptions; + + /** + * 构造函数,使用提示词列表和选项配置创建视频提示 + * + * @param prompts 提示词列表 + * @param videoOptions 视频生成选项配置 + */ + public VideoPrompt(List prompts, VideoOptions videoOptions) { + this.prompts = prompts; + this.videoOptions = videoOptions; + } + + /** + * 构造函数,使用单个提示词和选项配置创建视频提示 + * + * @param prompt 单个提示词 + * @param videoOptions 视频生成选项配置 + */ + public VideoPrompt(String prompt, VideoOptions videoOptions) { + this.prompts = List.of(prompt); + this.videoOptions = videoOptions; + } + + /** + * 获取提示词指令列表 + * + * @return 提示词列表 + */ + @Override + public List getInstructions() { + return this.prompts; + } + + /** + * 获取视频生成选项配置 + * + * @return 视频选项配置 + */ + @Override + public ModelOptions getOptions() { + return this.videoOptions; + } + + /** + * 获取第一个提示词 + * + * @return 第一个提示词字符串 + * @throws IllegalArgumentException 当提示词为空或null时抛出异常 + */ + public String getPrompt() { + Assert.notNull(this.prompts, "Prompt cannot be null."); + Assert.isTrue(!this.prompts.isEmpty(), "Prompt cannot be Empty."); + return this.prompts.get(0); + } +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/model/response/VideoResponse.java b/spring-ai-model/src/main/java/org/springframework/ai/video/model/response/VideoResponse.java new file mode 100644 index 00000000000..4d08bc2f696 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/model/response/VideoResponse.java @@ -0,0 +1,91 @@ +package org.springframework.ai.video.model.response; + +import org.springframework.ai.model.ModelResponse; +import org.springframework.ai.model.ResponseMetadata; + +import java.util.List; + +/** + * 视频响应类,用于封装视频生成API的响应结果 + * 实现了Spring AI的ModelResponse接口,提供对视频结果的统一访问 + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/29 + */ +public class VideoResponse implements ModelResponse { + + /** + * 视频结果列表,包含一个或多个视频生成结果 + */ + private List videoResults; + + /** + * 响应元数据,包含请求相关的元信息 + */ + private ResponseMetadata responseMetadata; + + /** + * 构造函数,使用视频结果列表和响应元数据创建VideoResponse实例 + * + * @param videoResults 视频结果列表 + * @param responseMetadata 响应元数据 + */ + public VideoResponse(List videoResults, ResponseMetadata responseMetadata) { + this.videoResults = videoResults; + this.responseMetadata = responseMetadata; + } + + /** + * 构造函数,仅使用视频结果列表创建VideoResponse实例 + * 元数据将被设置为null + * + * @param videoResults 视频结果列表 + */ + public VideoResponse(List videoResults) { + this.videoResults = videoResults; + this.responseMetadata = null; + } + + /** + * 构造函数,使用单个视频结果创建VideoResponse实例 + * 会将单个结果包装为列表形式 + * 元数据将被设置为null + * + * @param videoResult 单个视频结果 + */ + public VideoResponse(VideoResult videoResult) { + this.videoResults = List.of(videoResult); + this.responseMetadata = null; + } + + /** + * 获取第一个视频结果 + * + * @return 第一个视频结果,如果列表为空可能会抛出异常 + */ + @Override + public VideoResult getResult() { + return this.videoResults.get(0); + } + + /** + * 获取所有视频结果列表 + * + * @return 视频结果列表 + */ + @Override + public List getResults() { + return this.videoResults; + } + + /** + * 获取响应元数据 + * + * @return 响应元数据,可能为null + */ + @Override + public ResponseMetadata getMetadata() { + return this.responseMetadata; + } +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/model/response/VideoResult.java b/spring-ai-model/src/main/java/org/springframework/ai/video/model/response/VideoResult.java new file mode 100644 index 00000000000..9e19c47bacf --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/model/response/VideoResult.java @@ -0,0 +1,53 @@ +package org.springframework.ai.video.model.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.ai.model.ModelResult; +import org.springframework.ai.model.ResultMetadata; + +/** + * 视频处理结果封装类 + * + * 该类用于封装视频处理操作的结果,实现Spring AI的ModelResult接口, + * 专门用于处理String类型的输出结果。 + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/29 + */ +public class VideoResult implements ModelResult { + + /** + * 视频处理输出结果 + * 使用@JsonProperty注解将该字段映射为JSON中的"requestId"属性 + */ + @JsonProperty("requestId") + private String output; + + /** + * 结果元数据信息 + * 使用@JsonIgnore注解在序列化时忽略该字段 + */ + @JsonIgnore + private ResultMetadata resultMetadata; + + /** + * 获取视频处理的输出结果 + * + * @return String类型的处理结果 + */ + @Override + public String getOutput() { + return this.output; + } + + /** + * 获取结果的元数据信息 + * + * @return ResultMetadata元数据对象 + */ + @Override + public ResultMetadata getMetadata() { + return this.resultMetadata; + } +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/option/VideoOptions.java b/spring-ai-model/src/main/java/org/springframework/ai/video/option/VideoOptions.java new file mode 100644 index 00000000000..947d98cf37e --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/option/VideoOptions.java @@ -0,0 +1,62 @@ +package org.springframework.ai.video.option; + +import org.springframework.ai.model.ModelOptions; + +/** + * 视频生成选项接口 + * 该接口定义了视频生成所需的各种配置选项,继承自Spring AI的ModelOptions接口, + * 为视频生成模型提供统一的参数配置标准。 + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/29 + */ +public interface VideoOptions extends ModelOptions { + + /** + * 获取视频生成的主要提示词 + * 用于描述期望生成的视频内容 + * + * @return 提示词字符串 + */ + String getPrompt(); + + /** + * 获取使用的视频生成模型名称 + * + * @return 模型名称 + */ + String getModel(); + + /** + * 获取生成视频的尺寸规格 + * 格式通常为 "宽度x高度",如 "1920x1080" + * + * @return 视频尺寸字符串 + */ + String getImageSize(); + + /** + * 获取反向提示词 + * 用于指定不希望在生成视频中出现的内容 + * + * @return 反向提示词字符串 + */ + String getNegativePrompt(); + + /** + * 获取参考图像路径或URL + * 用于基于图像生成视频内容 + * + * @return 图像路径或URL字符串 + */ + String getImage(); + + /** + * 获取随机种子值 + * 用于控制视频生成的随机性,相同种子值可产生相似结果 + * + * @return 种子值字符串 + */ + Long getSeed(); +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/option/impl/VideoOptionsImpl.java b/spring-ai-model/src/main/java/org/springframework/ai/video/option/impl/VideoOptionsImpl.java new file mode 100644 index 00000000000..85144779db5 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/option/impl/VideoOptionsImpl.java @@ -0,0 +1,65 @@ +package org.springframework.ai.video.option.impl; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.springai.springaivideoextension.enhanced.option.VideoOptions; +import lombok.Builder; +import lombok.Data; + +/** + * 视频生成选项实现类 + * + * 该类用于封装视频生成所需的各种配置参数,通过Builder模式构建, + * 实现了VideoOptions接口,提供视频生成的完整配置选项。 + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/29 + */ +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class VideoOptionsImpl implements VideoOptions { + + /** + * 视频生成的主要提示词 + * 描述期望生成的视频内容 + */ + @JsonProperty("prompt") + private String prompt; + + /** + * 使用的AI模型名称 + * 指定用于视频生成的具体模型 + */ + @JsonProperty("model") + private String model; + + /** + * 生成视频的尺寸规格 + * 格式如: "1024x1024", "1280x720" 等 + */ + @JsonProperty("image_size") + private String imageSize; + + /** + * 负面提示词 + * 指定不希望在生成视频中出现的内容 + */ + @JsonProperty("negative_prompt") + private String negativePrompt; + + /** + * 参考图像路径或URL + * 用于图像到视频的生成任务 + */ + @JsonProperty("image") + private String image; + + /** + * 随机种子 + * 用于控制生成过程的随机性,相同种子可产生一致结果 + */ + @JsonProperty("seed") + private Long seed; +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/readme-details.md b/spring-ai-model/src/main/java/org/springframework/ai/video/readme-details.md new file mode 100644 index 00000000000..680aaf19ff8 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/readme-details.md @@ -0,0 +1,182 @@ +# Enhanced Details 文档 + +## 1. 文档介绍 + +1. 本技术文档旨在全面记录项目的完整演进历程,涵盖详细设计方案、架构决策考量以及关键实现要点等核心内容。 +2. 每个重要的演进节点将独立成章,采用结构化叙述方式,确保内容清晰且易于理解。 +3. 本文档基于[飞书源文档:SpringAI 深入探索](https://dcn7850oahi9.feishu.cn/docx/DDehdPBMSoGTycxmFTLcER4In0F?from=from_copylink)进行迭代更新,专注于视频模型的深度细化与实现。 + +## 2. 已完成功能模块 + +1. 基于 SpringAI 框架规范,已成功实现视频模型的核心功能体系,包括**任务创建、定时轮询、任务查询、任务存储**等完整解决方案。 +2. 当前框架基于**硅基流动**厂商的 API 接口规范进行开发构建。 +3. 得益于遵循 OpenAI 标准规范的设计理念,该方案天然兼容硅基流动、OpenAI 等主流厂商的 API 接口。 + +## 3. 后续演进规划 + +1. 持续优化架构设计,提升框架的厂商适配能力,实现对多元化 API 文档的兼容支持。 +2. 完整记录架构演进过程,形成标准化参考模板,并推广应用于图像模型等其他 AI 模型领域。 +3. 当前只实现了基于 Map 的存储方案,后续将会继续扩展 [VideoStorage.java](file:///D:/program-test2/programming/spring-ai-video-extension/src/main/java/com/springai/springaivideoextension/enhanced/storage/VideoStorage.java) 存储方案,引入 Redis 等多样化存储机制,并基于此推动代码设计的进一步优化与演进。 + +## 4. 架构演进——兼容多厂商API + +### 4.1 前言 + +1. 本次的架构演进,我们将会使用 **火山方舟官方** 提供的API文档作为演示案例。 +2. 我们将会提供完整的架构演进方案,这个 **演进的过程、决策过程** 将会作为其他模态模型的演进参考方案。 +3. 本次方案将会通过进一步的抽象、架构改造,实现从 **单规范多厂商适配** 到 **多规范多厂商适配** 的跃进。 + +### 4.2 前置分析 +1. 在架构演进开始之前,我们通常需要重新捋一遍当前的架构逻辑,并根据当前的架构逻辑,进行前置分析+方案初步设计 +2. 首先我们通过描述当前的架构逻辑,随后给出Mermaid流程图 + - 第一层(请求层): [VideoApi.java](api/VideoApi.java), 该类负责接收[VideoOptions.java](option/VideoOptions.java)参数,并将其作为请求体进行发送 + - 第二层(参数层): [VideoOptions.java](option/VideoOptions.java), 该类负责封装参数逻辑,一方面是作为请求时候的自定义参数,另一方面则是作为发送参数 + - 第三层(模型层): [VideoModel.java](model/VideoModel.java), 该类负责封装模型逻辑,并作为模型参数的接收者, 将参数封装为 [VideoApi.java](api/VideoApi.java), 并调用[VideoApi.java](api/VideoApi.java)进行请求发送 + - 第四层(客户端层): [VideoClient.java](client/VideoClient.java), 该类是核心类,负责统筹复杂的视频模型调用逻辑,具体可以进行如下拆分 + 1. 封装请求、发送请求: 通过 param().xx().getOutput(),将请求参数封装为 [VideoOptions.java](option/VideoOptions.java), 并调用 [VideoModel.java](model/VideoModel.java) 进行第一轮的请求发送 + 2. 接收响应、持久化响应: 当调用[VideoModel.java](model/VideoModel.java),且返回值返回到客户端层后,[VideoClient.java](client/VideoClient.java) 通过 [VideoStorage.java](storage/VideoStorage.java) 进行持久化,并将结果返回给调用方 + - 应用层额外(定时任务层): [VideoTimer.java](trimer/VideoTimer.java), 该类负责从[VideoStorage.java](storage/VideoStorage.java)中获取已经存储的 requestId,并调用[VideoApi.java](api/VideoApi.java)进行查询,并根据结果,选择性调用[VideoStorage.java](storage/VideoStorage.java), 更新对应视频状态 + +3. 我们通过Mermaid,画出当前的架构流程图 + ```mermaid + graph TD + %% 用户发起请求 + A[Application User] -->|1. 初始化请求| B[VideoClient] + + %% 参数构建阶段 + B -->|2. 创建参数构造器| C[ParamBuilder] + C -->|3. 链式调用设置参数| C + C -->|4. 构建完成参数| B + + %% API调用阶段 + B -->|5. 构建VideoPrompt| D[VideoPrompt] + D -->|包含配置| E[VideoOptions] + B -->|6. 调用视频模型| F[VideoModel] + F -->|7. 委托API调用| G[VideoApi] + G -->|8. HTTP请求| H[External Video Service] + H -->|9. 返回原始结果| G + G -->|10. 封装响应| F + F -->|11. 返回VideoResponse| B + + %% 存储阶段 + B -->|12. 检查存储配置| I{存储已配置?} + I -->|是| J[videoStorage.save] + I -->|否| K[log.warn 无存储配置] + J -->|13. 保存到存储| L{存储成功?} + L -->|是| M[记录requestId等信息] + L -->|否| N[log.error 存储失败] + + %% 异步状态轮询(独立流程) + O[VideoTimer Scheduler] -->|14. 定时触发| P[获取待处理请求] + P -->|15. 查询存储| Q[videoStorage.getPendingRequests] + Q -->|16. 获取requestIds列表| O + O -->|17. 逐个查询状态| R[VideoApi.queryStatus] + R -->|18. 调用外部服务| H + H -->|19. 返回最新状态| R + R -->|20. 更新存储状态| S[videoStorage.updateStatus] + + %% 最终返回用户 + M -->|21. 返回最终结果| T[VideoResponse to User] + K -->|21. 返回最终结果| T + N -->|21. 返回最终结果| T + + %% 样式定义 + classDef client fill:#e1f5fe,stroke:#01579b,stroke-width:2px + classDef api fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px + classDef storage fill:#fff3e0,stroke:#e65100,stroke-width:2px + classDef timer fill:#fce4ec,stroke:#c2185b,stroke-width:2px + classDef external fill:#ffebee,stroke:#b71c1c,stroke-width:2px + classDef builder fill:#f3e5f5,stroke:#4a148c,stroke-width:2px + classDef user fill:#e0f2f1,stroke:#004d40,stroke-width:2px + + class B,F client + class G,R api + class J,Q,S storage + class O,P timer + class H external + class C builder + class A,T user + ``` +4. 而是否可以实现多厂商、多规范的关键,就在于 **请求发送时候的请求体格式是否兼容** 。 +5. 不管是Options作为直传参数、还是Api作为请求发送类,关键在于 **请求的uri 和 请求的参数格式**。 +6. 这意味着,我们需要从 [VideoOptions.java](option/VideoOptions.java)接口入手,我们之前预留了该接口,并通过[VideoOptionsImpl.java](option/impl/VideoOptionsImpl.java)验证过,以接口+多态接收作为参数传递这一方法是可行的 +7. 我们可以运用这一层抽象,使用策略模式,将不同厂商的参数进行适配,并实现多厂商多规范的逻辑 +8. 在上述几点中,我们已经解决了参数传递层次的问题,而我们后续需要解决的,是多厂商、多规范下的兼容问题 +9. 我们已经知道,调用大模型的API,需要在请求头中放入api-key,同时需要提供base-url作为请求基地址,也需要uri后缀适配 +10. 我们针对VideoApi[VideoApi.java](api/VideoApi.java)目前的请求逻辑进行分析 + ```java + public class VideoApi { + // 这里的RestClient,会作为核心请求客户端,这里会进行请求发送,并返回结果 + private final RestClient restClient; + // 这里的videoPath,会作为视频上传的uri,这里会进行上传请求发送,并返回结果 + private final String videoPath; + // 这里的videoStatusPath,会作为视频查询的uri,这里会进行查询请求发送,并返回结果 + private final String videoStatusPath; + + /** + * 构造函数,用于创建VideoApi实例 + * + * @param baseUrl 基础URL地址,作为所有API请求的根路径 + * @param apiKey API密钥,用于身份验证,如果提供了有效的ApiKey则会添加到请求头中 + * @param headers 额外的请求头信息,允许自定义请求头参数 + * @param videoPath 视频上传接口的路径后缀,将与baseUrl组合成完整的上传接口URL + * @param videoStatusPath 视频状态查询接口的路径后缀,将与baseUrl组合成完整的查询接口URL + * @param restClientBuilder RestClient构建器,用于构建和配置HTTP客户端 + * @param responseErrorHandler 响应错误处理器,用于处理HTTP响应中的错误情况 + */ + public VideoApi(String baseUrl, ApiKey apiKey, MultiValueMap headers, String videoPath, String videoStatusPath, RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) { + // 使用RestClient.Builder构建RestClient实例 + this.restClient = restClientBuilder + // 设置基础URL + .baseUrl(baseUrl) + // 配置默认请求头 + .defaultHeaders((h) -> { + // 如果提供了有效的ApiKey(不是NoopApiKey),则设置Bearer认证头 + if (!(apiKey instanceof NoopApiKey)) { + h.setBearerAuth(apiKey.getValue()); + } + // 设置Content-Type为JSON格式 + h.setContentType(MediaType.APPLICATION_JSON); + // 添加所有自定义请求头 + h.addAll(headers); + }) + // 设置默认的错误响应处理器 + .defaultStatusHandler(responseErrorHandler) + // 构建RestClient实例 + .build(); + // 保存视频上传路径 + this.videoPath = videoPath; + // 保存视频状态查询路径 + this.videoStatusPath = videoStatusPath; + } + // ... + } + ``` + +11. 我们发现:apiKey、videoPath、videoStatusPath、restClient 的关系如下: + - apiKey 与 restClient 高度绑定,正常情况下,是不会直接更换 apiKey 的 + - videoPath、videoStatusPath 也与 restClient 有绑定,但是它们是可以随时更换的,可以通过 Options 参数自定义直传等方式解决 + - 而三者关系如何统筹,又是一道难题,单厂商,是单 uri,但是可以是多 apiKey + +12. 而如果为了多厂商、多规范适配,我们是否需要沿用 [飞书源文档](https://dcn7850oahi9.feishu.cn/docx/DDehdPBMSoGTycxmFTLcER4In0F?from=from_copylink) 的自定义集群逻辑,即: + - 通过配置文件配置多 VideoProperties + - 再通过多 VideoProperties 实例,配置多 VideoApi,通过 modelId 进行区分,这是**"面向模型"**的集群方案,即为单个模型,配置多 apiKey、videoPath、videoStatusPath、restClient + - 通过这样配置,我们可以实现的是:apiKey、videoPath、videoStatusPath、restClient 一体化且多配置隔离 + - 但是问题在于:大模型调用,在厂商提供方,会有很严格的并发限制,我们目前的 restClient 完全是同一类型线程池多实例,显然,这里的性能是严重溢出的 + +13. 此时我们需要考虑,是否要更换一下这样的侧重方式,也就是把 "面向模型" 的集群方案,更换为其他的方案: + - 因为 **"面向模型"** 的集群方案,本质上是单种模型,配置多厂商、多账户 + - 这种情况下,会带来最大程度的模型控制精细度,不过多 VideoApi 的创建确实会带来一定程度的性能冗余 + - 但是这种情况下也是最方便维护的,关键配置全部显性化,而不是隐藏到更深的层次 + - 且当前的方案,是比较贴合 SpringAI 原生的架构设计的,如果为了灵活度,而大幅度改动底层字段,势必会造成未来的兼容性问题 + +14. 因此我们依旧沿用原来的自定义集群方案,即 "面向模型" 的集群方案来管理多 apiKey、videoPath、videoStatusPath、restClient。 + +### 4.3 总结 +因此我们发现,架构设计的更新迭代需要从多个维度进行综合权衡,我们总结如下: +- 业务适配度:这是最重要的考量因素,需要评估新旧方案在业务场景中的适配程度是否在可接受范围内 +- 短期成本控制:需要对比旧方案当前的维护成本与新方案短期改造成本的大小关系,同时评估新方案带来的收益是否显著高于旧方案 +- 长期成本控制:评估新旧方案在长期维护方面的成本差异,以及新方案是否能带来比旧方案更显著的长期收益 +- 兼容性:新旧方案之间的兼容性是否满足要求。以本例而言,旧方案在SpringAI兼容性方面表现更优,能够更快速地跟进SpringAI的更新 +- 灵活性:对比新旧方案的灵活性差异是否在可接受范围内,主要体现在修改关键配置参数所需的成本 +- 需要注意的是:面向接口编程的抽象设计虽然能带来更高的灵活性,但同时也增加了维护成本。过度抽象往往会导致开发和维护成本的双重提升,因此我们需要把握好抽象的度,做到适度抽象 diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/readme.md b/spring-ai-model/src/main/java/org/springframework/ai/video/readme.md new file mode 100644 index 00000000000..e98b1e42389 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/readme.md @@ -0,0 +1,177 @@ +# Spring AI Video Extension + +本模块是基于 Spring AI 框架构建的视频生成扩展快速入门模块。它严格遵循 Spring AI 的核心设计哲学与架构规范,为开发者提供了一套完整的视频处理解决方案,涵盖视频生成、数据存储以及任务状态管理等核心功能。 + +## 📁 项目结构 + +``` +enhanced/ +├── api/ # 视频 API 客户端 +│ └── VideoApi.java # 与视频服务提供商 API 交互的客户端 +├── client/ # 视频客户端 +│ └── VideoClient.java # 提供给用户的视频操作客户端 +├── model/ # 视频模型相关类 +│ ├── VideoModel.java # 视频模型接口 +│ ├── impl/ +│ │ └── VideoModelImpl.java # 视频模型实现 +│ ├── request/ +│ │ └── VideoPrompt.java # 视频生成请求封装 +│ └── response/ +│ ├── VideoResponse.java # 视频生成响应封装 +│ └── VideoResult.java # 视频生成结果数据 +├── option/ # 视频选项配置 +│ ├── VideoOptions.java # 视频选项接口 +│ └── impl/ +│ └── VideoOptionsImpl.java # 视频选项实现 +├── storage/ # 视频存储管理 +│ ├── VideoStorage.java # 视频存储接口 +│ ├── VideoStorageStatus.java # 视频存储状态枚举 +│ └── impl/ +│ └── InMemoryVideoStorage.java # 内存存储实现 +└── trimer/ # 视频定时任务处理 + ├── VideoTimer.java # 视频任务定时扫描器 + ├── config/ + │ └── VideoTimerConfig.java # 定时任务配置 + ├── enums/ + │ └── VideoStorageStatus.java # 存储状态枚举 + └── response/ + └── VideoScanResponse.java # 视频扫描响应 +``` + +## ⚠️ 常见问题与踩坑提示 + +### 模型使用注意事项 +1. **模型选择**:系统支持两种模型: + - 文生视频模型:`Wan-AI/Wan2.2-T2V-A14B` + - 图生视频模型:`Wan-AI/Wan2.2-I2V-A14B` + - 不同模型有不同用途,不能混用 + +2. **参数配置**: + - `prompt`:视频生成提示词,不能为空 + - `image`:图生视频时必须提供,文生视频时应为空 + - `model`:必须指定正确的模型名称 + +### 定时任务注意事项 +1. **定时任务依赖配置**:确保在 `application.yaml` 中正确配置定时任务参数 +2. **任务状态更新**:任务状态更新时会同时更新状态和扫描结果数据 +3. **内存存储限制**:默认使用内存存储,重启服务会丢失数据,生产环境建议替换为持久化存储 + +### API调用注意事项 +1. **API Key配置**:确保在配置文件中正确设置API Key +2. **错误处理**:API调用包含重试机制,但仍需处理网络异常等边界情况 +3. **响应处理**:注意检查API响应状态码,非2xx状态码表示调用失败 + +## 🚀 核心功能 + +### 1. 视频生成模型 (VideoModel) +- 实现了 Spring AI 的 [Model](file:///D:/program-test2/programming/spring-ai-video-extension/src/main/java/org/springframework/ai/model/Model.java#L27-L51) 接口 +- 提供 [VideoModelImpl](file:///D:/program-test2/programming/spring-ai-video-extension/src/main/java/com/springai/springaivideoextension/enhanced/model/impl/VideoModelImpl.java#L32-L117) 实现类,用于调用视频 API 生成视频 +- 支持重试机制和监控注册 + +### 2. 视频客户端 (VideoClient) +- 提供用户友好的 API 接口 +- 支持链式调用和参数构建器模式 +- 集成视频存储功能 + +### 3. 视频存储 (VideoStorage) +- 提供 [VideoStorage](file:///D:/program-test2/programming/spring-ai-video-extension/src/main/java/com/springai/springaivideoextension/enhanced/storage/VideoStorage.java#L12-L76) 接口和 [InMemoryVideoStorage](file:///D:/program-test2/programming/spring-ai-video-extension/src/main/java/com/springai/springaivideoextension/enhanced/storage/impl/InMemoryVideoStorage.java#L15-L123) 内存实现 +- 支持视频任务的状态管理 +- 支持任务的持久化和检索 + +### 4. 定时任务处理 (VideoTimer) +- 自动扫描未完成的视频生成任务 +- 定期查询任务状态并更新存储中的状态 +- 支持超时处理和任务清理 + +## ⚙️ 配置选项 + +视频生成支持以下配置选项: + +- `prompt`: 视频生成提示词 +- `model`: 使用的模型名称 +- `imageSize`: 生成视频的尺寸 +- `negativePrompt`: 负面提示词,排除不希望出现的内容 +- `image`: 参考图像路径 +- `seed`: 随机种子,用于控制生成的一致性 + +## 🧪 使用示例 + +```java +// 1. 构建视频选项 +VideoOptions options = VideoOptionsImpl.builder() + .prompt("一只柯基在沙滩奔跑") + .model("Wan-AI/Wan2.2-T2V-A14B") + .negativePrompt("模糊,低质量") + .build(); + +// 2. 构建视频 API 客户端 +VideoApi videoApi = VideoApi.builder() + .apiKey("your-api-key") + .baseUrl("https://api.video-service.com") + .videoPath("v1/video/submit") + .build(); + +// 3. 构建视频模型和客户端 +VideoModel videoModel = new VideoModelImpl(videoApi); +VideoStorage videoStorage = new InMemoryVideoStorage(); +VideoClient videoClient = new VideoClient(videoModel, videoStorage); + +// 4. 调用视频生成 +String requestId = videoClient.param() + .prompt("一只柯基在沙滩奔跑") + .model("Wan-AI/Wan2.2-T2V-A14B") + .negativePrompt("模糊,低质量") + .getOutput(); + +System.out.println("视频生成请求ID: " + requestId); +``` + +## ⚙️ 定时任务配置 + +| 配置项 | 说明 | 默认值 | +|-----------------------------|--------------|-------------------| +| `ai.video.timer.enabled` | 是否启用轮询定时任务 | `true` | +| `ai.video.timer.timeout` | 任务超时时间(毫秒) | `300000` (5分钟) | +| `ai.video.timer.ttl` | 任务存储 TTL(毫秒) | `86400000` (24小时) | +| `ai.video.timer.interval` | 轮询间隔(毫秒) | `30000` (30秒) | +| `ai.video.timer.key-prefix` | 存储 key 前缀 | `in:memory:key:` | + +> ⚠️ 注意:`ai.video.timer.key-prefix` 默认值已修正为 `in:memory:key:`(以冒号结尾),旧版本缺少末尾冒号可能导致键值处理异常 + +## 🔄 工作流程 + +``` +sequenceDiagram + participant Client + participant API_Server + participant Cache + participant Worker + + Client->>API_Server: 1. 提交视频生成请求 + API_Server->>Client: 2. 返回RequestId + API_Server->>Cache: 3. 存储RequestId和初始状态 + Client->>API_Server: 4. 查询状态(可选手动轮询) + + loop 自动轮询流程 + Worker->>Cache: 5. 定时扫描待处理RequestId + Cache->>Worker: 6. 返回未完成的任务列表 + Worker->>API_Server: 7. 内部查询生成状态 + API_Server->>Worker: 8. 返回最新状态 + alt 状态=Succeed/Failed + Worker->>Cache: 9. 更新最终状态和结果 + else 状态=InProgress + Worker->>Cache: 11. 更新进度 + end + end +``` + +## 📦 依赖 + +- Spring Boot 3.5.6 +- Spring AI 1.0.2 +- Spring Web +- Spring AI OpenAI Starter + +## 📄 许可证 + +本项目基于 Spring AI 框架开发,遵循相应的许可证协议。 \ No newline at end of file diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/storage/VideoStorage.java b/spring-ai-model/src/main/java/org/springframework/ai/video/storage/VideoStorage.java new file mode 100644 index 00000000000..2bd0c824c7f --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/storage/VideoStorage.java @@ -0,0 +1,79 @@ +package org.springframework.ai.video.storage; + + +import com.springai.springaivideoextension.enhanced.trimer.response.VideoScanResponse; + +import java.util.Collection; + +/** + * 视频存储接口,提供视频的保存和检索功能 + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/30 + */ +public interface VideoStorage { + + /** + * 保存视频数据,使用默认ID + * + * @param videoValue 视频数据内容 + * @return 保存成功返回true,否则返回false + */ + boolean save(String videoValue); + + /** + * 根据指定ID保存视频数据 + * + * @param saveId 视频存储ID + * @param videoValue 视频数据内容 + * @return 保存成功返回true,否则返回false + */ + boolean save(String saveId, Object videoValue); + + /** + * 根据ID查找视频数据 + * + * @param saveId 视频存储ID + * @return 返回找到的视频数据对象,未找到返回null + */ + Object findVideoById(String saveId); + + /** + * 获取所有保存的ID + * + * @return 返回所有保存的ID列表 + */ + Collection keys(); + + /** + * 获取所有未处理的视频任务键值 + * + * @return 键值列表 + */ + Collection keysForInProgress(); + + /** + * 更新视频任务状态 + * + * @param keyEnd 视频任务键值 + * @param videoStorageStatus 新的视频任务状态 + * @param scanResponse + */ + void changeStatus(String keyEnd, VideoStorageStatus videoStorageStatus, VideoScanResponse scanResponse); + + /** + * 移除默认前缀 + * + * @param keys 键值列表 + * @return 移除默认前缀后的键值列表 + */ + Collection removeDefaultPrefix(Collection keys); + + /** + * 删除视频任务 + * + * @param key 视频任务键值 + */ + void delete(String key); +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/storage/VideoStorageStatus.java b/spring-ai-model/src/main/java/org/springframework/ai/video/storage/VideoStorageStatus.java new file mode 100644 index 00000000000..8a0709abbd1 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/storage/VideoStorageStatus.java @@ -0,0 +1,47 @@ +package org.springframework.ai.video.storage; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/30 + */ +@Getter +@RequiredArgsConstructor +public enum VideoStorageStatus { + /** + * 任务已创建,等待处理 + */ + PENDING("pending", "任务排队中"), + + /** + * 任务正在处理中 + */ + PROCESSING("processing", "视频生成中"), + + /** + * 任务成功完成 + */ + SUCCESS("success", "处理成功"), + + /** + * 任务处理失败(可重试) + */ + FAILED("failed", "处理失败"), + + /** + * 任务超时(需人工干预) + */ + TIME_OUT("time_out", "处理超时"), + + /** + * 任务被取消 + */ + CANCELLED("cancelled", "已取消"); + + private final String code; + private final String desc; + +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/storage/impl/InMemoryVideoStorage.java b/spring-ai-model/src/main/java/org/springframework/ai/video/storage/impl/InMemoryVideoStorage.java new file mode 100644 index 00000000000..0e75bbbc4e3 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/storage/impl/InMemoryVideoStorage.java @@ -0,0 +1,192 @@ +package org.springframework.ai.video.storage.impl; + +import com.springai.springaivideoextension.enhanced.storage.VideoStorage; +import com.springai.springaivideoextension.enhanced.storage.VideoStorageStatus; +import com.springai.springaivideoextension.enhanced.trimer.response.VideoScanResponse; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.*; + +/** + * 基于内存的视频存储实现类 + * 提供视频数据的保存和查询功能,使用内存Map作为存储介质 + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/30 + */ +@Data +@Slf4j +@Component +public class InMemoryVideoStorage implements VideoStorage { + + /** + * 默认键前缀,用于构建存储键值,这里配置到配置文件中,在这里我们进行默认值的配置 + */ + @Value("${ai.video.trimer.key-prefix:in:memory:key:}") + private String defaultKey; + + /** + * 视频数据存储映射表 + * key: 完整存储键值 (defaultKey + saveId) + * value: 视频数据内容 + */ + private final Map videoStorage = new HashMap<>(); + + /** + * 保存视频数据,使用默认ID + * 该方法会自动构建包含默认前缀和视频内容的完整ID + * + * @param videoValue 视频数据内容 + * @return 保存成功返回true,否则返回false + */ + @Override + public boolean save(String videoValue) { + log.info("未指定前缀,将使用默认前缀: {}", defaultKey); + String saveId = this.defaultKey + videoValue; + return this.save(saveId, new VideoScanResponse(videoValue)); + } + + /** + * 根据指定ID保存视频数据 + * 将视频数据存储到内存映射表中,键为defaultKey与saveId的组合 + * + * @param saveId 视频存储ID,将与默认前缀组合成完整键值 + * @param videoValue 视频数据内容,将转换为字符串存储 + * @return 保存成功返回true,否则返回false + */ + @Override + public boolean save(String saveId, Object videoValue) { + log.info("开始保存视频数据: {}", saveId); + try { + this.videoStorage.put(saveId, (VideoScanResponse) videoValue); + log.info("保存视频数据成功: {}", saveId); + return true; + } catch (Exception e) { + log.error("保存视频数据失败: {}", saveId, e); + return false; + } + } + + /** + * 根据ID查找视频数据 + * 从内存映射表中检索指定ID的视频数据 + * + * @param saveId 视频存储ID,将与默认前缀组合成完整键值 + * @return 返回找到的视频数据对象,未找到返回null + */ + @Override + public Object findVideoById(String saveId) { + String key = this.defaultKey + saveId; + log.info("开始查找视频数据: {}", key); + VideoScanResponse videoData = this.videoStorage.get(key); + if (videoData != null) { + log.info("找到视频数据: {}", key); + return videoData; + } else { + log.info("未找到视频数据: {}", key); + return null; + } + } + + /** + * 获取所有保存的ID + * + * @return 返回所有保存的ID列表 + */ + @Override + public Collection keys() { + log.info("开始获取所有保存的ID"); + Set strings = this.videoStorage.keySet(); + log.info("获取所有保存的ID成功: {}", strings); + return strings; + } + + + + /** + * 获取所有正在进行中的视频任务键值 + * 遍历视频存储映射表,筛选出状态为IN_QUEUE或IN_PROGRESS的视频任务 + * 这些任务表示正在处理队列中或处理中 + * + * @return 处理中和队列中状态的视频任务键值列表 + */ + @Override + public Collection keysForInProgress() { + log.info("开始获取所有正在进行中的视频任务键值"); + return videoStorage.entrySet().stream() + .filter(entry -> entry.getValue().getStatus() == VideoScanResponse.Status.IN_QUEUE || + entry.getValue().getStatus() == VideoScanResponse.Status.IN_PROGRESS) + .map(Map.Entry::getKey) + .toList(); + } + + /** + * 更新视频任务状态 + * + * @param keyEnd 视频任务键值 + * @param videoStorageStatus 新的视频任务状态 + * @param scanResponse 视频扫描结果 + */ + @Override + public void changeStatus(String keyEnd, VideoStorageStatus videoStorageStatus, VideoScanResponse scanResponse) { + scanResponse.setRequestId(keyEnd); + + String key = this.defaultKey + keyEnd; + + log.info("开始更新视频任务状态: {}, 新状态: {}", key, videoStorageStatus); + VideoScanResponse videoData = this.videoStorage.get(key); + + if (videoData != null) { + // 根据传入的状态更新视频任务状态 + switch (videoStorageStatus) { + case TIME_OUT, FAILED: + videoData.setStatus(VideoScanResponse.Status.FAILED); + break; + case SUCCESS: + videoData.setStatus(VideoScanResponse.Status.SUCCEED); + this.videoStorage.put(key, scanResponse); + break; + default: + log.warn("未知的状态: {}", videoStorageStatus); + break; + } + log.info("更新视频任务状态成功: {}, 新状态: {}", key, videoData.getStatus()); + } else { + log.warn("未找到对应的视频任务: {}", key); + } + } + + /** + * 移除默认前缀 + * + * @param keys 键值列表 + * @return 移除默认前缀后的键值列表 + */ + @Override + public Collection removeDefaultPrefix(Collection keys) { + log.info("开始移除默认前缀: {}", keys); + List strings = keys.stream().map(key -> key.replace(this.defaultKey, "")).toList(); + log.debug("移除默认前缀成功: {}", strings); + return strings; + } + + /** + * 删除视频任务 + * + * @param key 视频任务键值 + */ + @Override + public void delete(String key) { + try { + log.info("开始删除视频任务: {}", key); + this.videoStorage.remove(key); + } catch (Exception e) { + log.error("删除视频任务失败: {}", key, e); + } + } + +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/VideoTimer.java b/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/VideoTimer.java new file mode 100644 index 00000000000..4e4205d09a9 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/VideoTimer.java @@ -0,0 +1,131 @@ +package org.springframework.ai.video.trimer; + +import com.springai.springaivideoextension.enhanced.api.VideoApi; +import com.springai.springaivideoextension.enhanced.storage.VideoStorage; +import com.springai.springaivideoextension.enhanced.storage.VideoStorageStatus; +import com.springai.springaivideoextension.enhanced.trimer.response.VideoScanResponse; +import jakarta.annotation.PostConstruct; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; + +import java.util.Collection; + +/** + * 视频定时任务处理器 + * + * 该类负责定期扫描未处理成功的视频任务,并根据处理结果更新任务状态。 + * 主要功能包括: + * 1. 定时扫描未成功的视频任务 + * 2. 调用视频API处理任务 + * 3. 根据处理结果更新任务状态(成功/超时) + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/30 + */ +@Data +@Slf4j +@RequiredArgsConstructor +public class VideoTimer { + + private Long videoTimeout; + private Long ttl; + + @PostConstruct + public void init() { + log.info("初始化视频定时任务"); + } + + /** + * 视频存储实现类, 暂时使用内存存储直接实现,后续可以自行修改 + */ + private final VideoStorage videoStorage; + + /** + * 视频API实现类 + */ + private final VideoApi videoApi; + + /** + * 视频扫描定时任务 + * + * 该方法会定期执行,扫描所有未处理成功的视频任务并尝试处理。 + * 执行间隔可以通过配置项 ai.video.trimer.interval 进行设置,默认为30秒。 + * + * 处理逻辑: + * 1. 获取所有未成功的任务键值 + * 2. 移除默认前缀以获取真实键值 + * 3. 遍历每个任务键值,调用视频API进行处理 + * 4. 根据API返回结果更新任务状态: + * - 处理成功:更新状态为SUCCESS + * - 处理失败且超时:更新状态为TIME_OUT + */ + @Scheduled(fixedDelayString = "${ai.video.trimer.interval:10000}") + public void videoTask() { + log.info("视频扫描任务开始执行"); + + // 获取所有未处理成功的视频任务键值集合 + Collection keys = videoStorage.keysForInProgress(); + + // 移除键值中的默认前缀,获取真实的任务标识 + keys = videoStorage.removeDefaultPrefix(keys); + + // 遍历所有未成功的任务 + for (String key : keys) { + log.info("开始扫描未成功数据: {}", key); + + // 调用视频API处理当前任务 + ResponseEntity responseEntity = videoApi.createVideo(key); + log.info("扫描结果: {}", responseEntity); + + // 解析API响应结果 + VideoScanResponse scanResponse = responseEntity.getBody(); + + if (scanResponse == null) { + log.error("API返回结果为空"); + continue; + } + + // 根据处理结果更新任务状态 + if (scanResponse.isSuccess()) { + log.info("开始处理成功数据: {}", key); + // 任务处理成功,更新状态为SUCCESS + videoStorage.changeStatus(key, VideoStorageStatus.SUCCESS, scanResponse); + } else if (scanResponse.isFailed() && isTimeOut(scanResponse.getStartTime())) { + log.info("数据处理超时: {}", key); + // 任务处理超时,更新状态为TIME_OUT + videoStorage.changeStatus(key, VideoStorageStatus.TIME_OUT, scanResponse); + } else if (isTtl(scanResponse.getStartTime())) { + log.info("数据处理已过期: {}", key); + videoStorage.delete(key); + } + } + + log.info("视频扫描任务结束"); + } + + /** + * 判断任务是否已过期 + * + * @param startTime 任务开始时间 + * @return 任务是否已超时 + */ + private boolean isTtl(Long startTime) { + return System.currentTimeMillis() - startTime > ttl; + } + + /** + * 判断任务是否超时 + * + * @param startTime 任务开始时间 + * @return 任务是否超时 + */ + private boolean isTimeOut(Long startTime) { + return System.currentTimeMillis() - startTime > videoTimeout; + } + + +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/config/VideoTimerConfig.java b/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/config/VideoTimerConfig.java new file mode 100644 index 00000000000..72f10fd67ea --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/config/VideoTimerConfig.java @@ -0,0 +1,61 @@ +package org.springframework.ai.video.trimer.config; + +import com.springai.springaivideoextension.enhanced.api.VideoApi; +import com.springai.springaivideoextension.enhanced.storage.VideoStorage; +import com.springai.springaivideoextension.enhanced.trimer.VideoTimer; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 视频定时器配置类 + * + * 该配置类用于管理视频处理的定时任务相关配置,包括是否启用定时器、超时时间及TTL设置 + * 通过读取 application.yml 中 ai.video.trimer 前缀的配置项进行参数设置 + * + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/30 + */ +@Data +@Configuration +@ConfigurationProperties(prefix = "ai.video.trimer") +public class VideoTimerConfig { + + /** + * 是否启用视频定时器功能,默认为true + */ + private boolean enabled = true; + + /** + * 视频处理超时时间(毫秒),默认为300秒 + */ + private Long timeout = 300000L; + + /** + * 视频存储的生存时间(毫秒),默认为24小时 + */ + private Long ttl = 86400000L; + + /** + * 创建并配置视频定时器Bean + * + * @param videoStorage 视频存储服务 + * @param videoApi 视频API服务 + * @return 配置好的视频定时器实例,如果未启用则返回null + */ + @Bean + public VideoTimer videoTimer(VideoStorage videoStorage, VideoApi videoApi) { + // 如果未启用定时器功能,则不创建Bean实例 + if (!enabled) { + return null; + } + + // 创建视频定时器实例并设置相关参数 + VideoTimer videoTimer = new VideoTimer(videoStorage, videoApi); + videoTimer.setVideoTimeout(timeout); + videoTimer.setTtl(ttl); + return videoTimer; + } +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/enums/VideoStorageStatus.java b/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/enums/VideoStorageStatus.java new file mode 100644 index 00000000000..66e5550a454 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/enums/VideoStorageStatus.java @@ -0,0 +1,47 @@ +package org.springframework.ai.video.trimer.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/30 + */ +@Getter +@RequiredArgsConstructor +public enum VideoStorageStatus { + /** + * 任务已创建,等待处理 + */ + PENDING("pending", "任务排队中"), + + /** + * 任务正在处理中 + */ + PROCESSING("processing", "视频生成中"), + + /** + * 任务成功完成 + */ + SUCCESS("success", "处理成功"), + + /** + * 任务处理失败(可重试) + */ + FAILED("failed", "处理失败"), + + /** + * 任务超时(需人工干预) + */ + TIME_OUT("time_out", "处理超时"), + + /** + * 任务被取消 + */ + CANCELLED("cancelled", "已取消"); + + private final String code; + private final String desc; + +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/response/VideoScanResponse.java b/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/response/VideoScanResponse.java new file mode 100644 index 00000000000..75f210bd145 --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/video/trimer/response/VideoScanResponse.java @@ -0,0 +1,107 @@ +package org.springframework.ai.video.trimer.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; +import java.util.Objects; + +/** + * @author 王玉涛 + * @version 1.0 + * @since 2025/9/30 + */ +@Data +public class VideoScanResponse { + + /** + * 创建一个空的响应对象, 用于初始化任务状态 + */ + public VideoScanResponse() { + this.startTime = System.currentTimeMillis(); + this.status = Status.IN_QUEUE; + } + + /** + * 创建一个空的响应对象, 用于初始化任务状态 + */ + public VideoScanResponse(String requestId) { + this.requestId = requestId; + this.startTime = System.currentTimeMillis(); + this.status = Status.IN_QUEUE; + } + + /** + * 获取任务处理失败的原因 + * + * @return 任务处理失败的原因 + */ + public boolean isFailed() { + return this.status == Status.FAILED; + } + + /** + * 任务当前状态 + */ + public enum Status { + @JsonProperty("InQueue") IN_QUEUE("InQueue"), + @JsonProperty("InProgress") IN_PROGRESS("InProgress"), + @JsonProperty("Succeed") SUCCEED("Succeed"), + @JsonProperty("Failed") FAILED("Failed"); + + private final String apiValue; + Status(String apiValue) { this.apiValue = apiValue; } + } + + //----- 所有状态均返回的字段 -----// + @JsonProperty("status") + private Status status; + @JsonIgnore + private String requestId; + @JsonIgnore + private Long startTime; + + //----- 仅失败时返回的字段 -----// + @JsonProperty("reason") + private String reason; + + //----- 仅成功时返回的字段 -----// + @JsonProperty("results") + private Results results; + + @Data + public static class Results { + @JsonProperty("videos") + private List