diff --git a/examples/misc/persistent-chat-meilisearch.php b/examples/misc/persistent-chat-meilisearch.php new file mode 100644 index 00000000..51d758df --- /dev/null +++ b/examples/misc/persistent-chat-meilisearch.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Bridge\Meilisearch\MessageStore; +use Symfony\AI\Agent\Chat; +use Symfony\AI\Platform\Bridge\OpenAi\Gpt; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); +$llm = new Gpt(Gpt::GPT_4O_MINI); + +$agent = new Agent($platform, $llm, logger: logger()); +$store = new MessageStore( + http_client(), + env('MEILISEARCH_HOST'), + env('MEILISEARCH_API_KEY'), + 'chat', +); +$store->initialize(); + +$chat = new Chat($agent, $store); + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), +); + +$chat->initiate($messages); +$chat->submit(Message::ofUser('My name is Christopher.')); +$message = $chat->submit(Message::ofUser('What is my name?')); + +echo $message->content.\PHP_EOL; diff --git a/examples/misc/persistent-chat.php b/examples/misc/persistent-chat-memory.php similarity index 100% rename from examples/misc/persistent-chat.php rename to examples/misc/persistent-chat-memory.php diff --git a/src/agent/src/Bridge/Meilisearch/MessageStore.php b/src/agent/src/Bridge/Meilisearch/MessageStore.php new file mode 100644 index 00000000..97481c87 --- /dev/null +++ b/src/agent/src/Bridge/Meilisearch/MessageStore.php @@ -0,0 +1,177 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Bridge\Meilisearch; + +use Symfony\AI\Agent\Chat\InitializableMessageStoreInterface; +use Symfony\AI\Agent\Chat\MessageStoreInterface; +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Exception\LogicException; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Content\Audio; +use Symfony\AI\Platform\Message\Content\ContentInterface; +use Symfony\AI\Platform\Message\Content\DocumentUrl; +use Symfony\AI\Platform\Message\Content\File; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\AI\Platform\Result\ToolCall; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Guillaume Loulier + */ +final readonly class MessageStore implements InitializableMessageStoreInterface, MessageStoreInterface +{ + public function __construct( + private HttpClientInterface $httpClient, + private string $endpointUrl, + #[\SensitiveParameter] private string $apiKey, + private string $indexName, + ) { + } + + public function save(MessageBagInterface $messages): void + { + $messages = $messages->getMessages(); + + $this->request('PUT', \sprintf('indexes/%s/documents', $this->indexName), array_map( + $this->convertToIndexableArray(...), + $messages, + )); + } + + public function load(): MessageBagInterface + { + $messages = $this->request('POST', \sprintf('indexes/%s/documents/fetch', $this->indexName)); + + return new MessageBag(...array_map($this->convertToMessage(...), $messages['results'])); + } + + public function clear(): void + { + $this->request('DELETE', \sprintf('indexes/%s/documents', $this->indexName)); + } + + public function initialize(array $options = []): void + { + if ([] !== $options) { + throw new InvalidArgumentException('No supported options.'); + } + + $this->request('POST', 'indexes', [ + 'uid' => $this->indexName, + 'primaryKey' => 'id', + ]); + } + + /** + * @param array|list> $payload + * + * @return array + */ + private function request(string $method, string $endpoint, array $payload = []): array + { + $url = \sprintf('%s/%s', $this->endpointUrl, $endpoint); + $result = $this->httpClient->request($method, $url, [ + 'headers' => [ + 'Authorization' => \sprintf('Bearer %s', $this->apiKey), + ], + 'json' => [] !== $payload ? $payload : new \stdClass(), + ]); + + return $result->toArray(); + } + + /** + * @return array + */ + private function convertToIndexableArray(MessageInterface $message): array + { + $toolsCalls = []; + + if ($message instanceof AssistantMessage && $message->hasToolCalls()) { + $toolsCalls = array_map( + static fn (ToolCall $toolCall): array => $toolCall->jsonSerialize(), + $message->toolCalls, + ); + } + + if ($message instanceof ToolCallMessage) { + $toolsCalls = $message->toolCall->jsonSerialize(); + } + + return [ + 'id' => $message->getId()->toRfc4122(), + 'type' => $message::class, + 'content' => ($message instanceof SystemMessage || $message instanceof AssistantMessage || $message instanceof ToolCallMessage) ? $message->content : '', + 'contentAsBase64' => ($message instanceof UserMessage && [] !== $message->content) ? array_map( + static fn (ContentInterface $content) => [ + 'type' => $content::class, + 'content' => match ($content::class) { + Text::class => $content->text, + File::class, + Image::class, + Audio::class => $content->asBase64(), + ImageUrl::class, + DocumentUrl::class => $content->url, + default => throw new LogicException(\sprintf('Unknown content type "%s".', $content::class)), + }, + ], + $message->content, + ) : [], + 'toolsCalls' => $toolsCalls, + ]; + } + + /** + * @param array $payload + */ + private function convertToMessage(array $payload): MessageInterface + { + $type = $payload['type']; + $content = $payload['content'] ?? ''; + $contentAsBase64 = $payload['contentAsBase64'] ?? []; + + return match ($type) { + SystemMessage::class => new SystemMessage($content), + AssistantMessage::class => new AssistantMessage($content, array_map( + static fn (array $toolsCall): ToolCall => new ToolCall( + $toolsCall['id'], + $toolsCall['function']['name'], + json_decode($toolsCall['function']['arguments'], true) + ), + $payload['toolsCalls'], + )), + UserMessage::class => new UserMessage(...array_map( + static fn (array $contentAsBase64) => \in_array($contentAsBase64['type'], [File::class, Image::class, Audio::class], true) + ? $contentAsBase64['type']::fromDataUrl($contentAsBase64['content']) + : new $contentAsBase64['type']($contentAsBase64['content']), + $contentAsBase64, + )), + ToolCallMessage::class => new ToolCallMessage( + new ToolCall( + $payload['toolsCalls']['id'], + $payload['toolsCalls']['function']['name'], + json_decode($payload['toolsCalls']['function']['arguments'], true) + ), + $content + ), + default => throw new LogicException(\sprintf('Unknown message type "%s".', $type)), + }; + } +} diff --git a/src/agent/src/Chat/InitializableMessageStoreInterface.php b/src/agent/src/Chat/InitializableMessageStoreInterface.php new file mode 100644 index 00000000..e9019325 --- /dev/null +++ b/src/agent/src/Chat/InitializableMessageStoreInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Chat; + +/** + * @author Guillaume Loulier + */ +interface InitializableMessageStoreInterface +{ + /** + * @param array $options + */ + public function initialize(array $options = []): void; +} diff --git a/src/agent/tests/Bridge/Meilisearch/MessageStoreTest.php b/src/agent/tests/Bridge/Meilisearch/MessageStoreTest.php new file mode 100644 index 00000000..a76e3f6b --- /dev/null +++ b/src/agent/tests/Bridge/Meilisearch/MessageStoreTest.php @@ -0,0 +1,310 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Bridge\Meilisearch; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Bridge\Meilisearch\MessageStore; +use Symfony\AI\Platform\Message\AssistantMessage; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\SystemMessage; +use Symfony\AI\Platform\Message\ToolCallMessage; +use Symfony\AI\Platform\Message\UserMessage; +use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Uid\Uuid; + +#[CoversClass(MessageStore::class)] +final class MessageStoreTest extends TestCase +{ + public function testStoreCannotInitializeOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'message' => 'error', + 'code' => 'index_creation_failed', + 'type' => 'invalid_request', + 'link' => 'https://docs.meilisearch.com/errors#index_creation_failed', + ], [ + 'http_code' => 400, + ]), + ], 'http://localhost:7700'); + + $store = new MessageStore( + $httpClient, + 'http://localhost:7700', + 'test', + 'test', + ); + + self::expectException(ClientException::class); + self::expectExceptionMessage('HTTP 400 returned for "http://localhost:7700/indexes".'); + self::expectExceptionCode(400); + $store->initialize(); + } + + public function testStoreCanInitialize() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'taskUid' => 1, + 'indexUid' => 'test', + 'status' => 'enqueued', + 'type' => 'indexCreation', + 'enqueuedAt' => '2025-01-01T00:00:00Z', + ], [ + 'http_code' => 202, + ]), + new JsonMockResponse([ + 'taskUid' => 2, + 'indexUid' => 'test', + 'status' => 'enqueued', + 'type' => 'indexUpdate', + 'enqueuedAt' => '2025-01-01T01:00:00Z', + ], [ + 'http_code' => 202, + ]), + ], 'http://localhost:7700'); + + $store = new MessageStore( + $httpClient, + 'http://localhost:7700', + 'test', + 'test', + ); + + $store->initialize(); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testStoreCannotAddOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'message' => 'error', + 'code' => 'invalid_document_fields', + 'type' => 'invalid_request', + 'link' => 'https://docs.meilisearch.com/errors#invalid_document_fields', + ], [ + 'http_code' => 400, + ]), + ], 'http://localhost:7700'); + + $store = new MessageStore( + $httpClient, + 'http://localhost:7700', + 'test', + 'test', + ); + + self::expectException(ClientException::class); + self::expectExceptionMessage('HTTP 400 returned for "http://localhost:7700/indexes/test/documents".'); + self::expectExceptionCode(400); + $store->save(new MessageBag(Message::ofUser('Hello there'))); + } + + public function testStoreCanAdd() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'taskUid' => 1, + 'indexUid' => 'test', + 'status' => 'enqueued', + 'type' => 'documentAdditionOrUpdate', + 'enqueuedAt' => '2025-01-01T00:00:00Z', + ], [ + 'http_code' => 202, + ]), + ], 'http://localhost:7700'); + + $store = new MessageStore( + $httpClient, + 'http://localhost:7700', + 'test', + 'test', + ); + + $store->save(new MessageBag(Message::ofUser('Hello there'))); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testStoreCannotRetrieveMessagesOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'message' => 'error', + 'code' => 'document_not_found', + 'type' => 'invalid_request', + 'link' => 'https://docs.meilisearch.com/errors#document_not_found', + ], [ + 'http_code' => 400, + ]), + ], 'http://localhost:7700'); + + $store = new MessageStore( + $httpClient, + 'http://localhost:7700', + 'test', + 'test', + ); + + self::expectException(ClientException::class); + self::expectExceptionMessage('HTTP 400 returned for "http://localhost:7700/indexes/test/documents/fetch".'); + self::expectExceptionCode(400); + $store->load(); + } + + #[DataProvider('provideMessages')] + public function testStoreCanRetrieveMessages(array $payload) + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'results' => [ + $payload, + ], + ], [ + 'http_code' => 200, + ]), + ], 'http://localhost:7700'); + + $store = new MessageStore( + $httpClient, + 'http://localhost:7700', + 'test', + 'test', + ); + + $messageBag = $store->load(); + + $this->assertCount(1, $messageBag); + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testStoreCannotDeleteMessagesOnInvalidResponse() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'message' => 'error', + 'code' => 'index_not_found', + 'type' => 'invalid_request', + 'link' => 'https://docs.meilisearch.com/errors#index_not_found', + ], [ + 'http_code' => 400, + ]), + ], 'http://localhost:7700'); + + $store = new MessageStore( + $httpClient, + 'http://localhost:7700', + 'test', + 'test', + ); + + self::expectException(ClientException::class); + self::expectExceptionMessage('HTTP 400 returned for "http://localhost:7700/indexes/test/documents".'); + self::expectExceptionCode(400); + $store->clear(); + } + + public function testStoreCanDelete() + { + $httpClient = new MockHttpClient([ + new JsonMockResponse([ + 'taskUid' => 1, + 'indexUid' => 'test', + 'status' => 'enqueued', + 'type' => 'indexDeletion', + 'enqueuedAt' => '2025-01-01T00:00:00Z', + ], [ + 'http_code' => 200, + ]), + ], 'http://localhost:7700'); + + $store = new MessageStore( + $httpClient, + 'http://localhost:7700', + 'test', + 'test', + ); + + $store->clear(); + + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public static function provideMessages(): \Generator + { + yield UserMessage::class => [ + [ + 'id' => Uuid::v7()->toRfc4122(), + 'type' => UserMessage::class, + 'content' => '', + 'contentAsBase64' => [ + [ + 'type' => Text::class, + 'content' => 'What is the Symfony framework?', + ], + ], + 'toolsCalls' => [], + ], + ]; + yield SystemMessage::class => [ + [ + 'id' => Uuid::v7()->toRfc4122(), + 'type' => SystemMessage::class, + 'content' => 'Hello there', + 'contentAsBase64' => [], + 'toolsCalls' => [], + ], + ]; + yield AssistantMessage::class => [ + [ + 'id' => Uuid::v7()->toRfc4122(), + 'type' => AssistantMessage::class, + 'content' => 'Hello there', + 'contentAsBase64' => [], + 'toolsCalls' => [ + [ + 'id' => '1', + 'name' => 'foo', + 'function' => [ + 'name' => 'foo', + 'arguments' => '{}', + ], + ], + ], + ], + ]; + yield ToolCallMessage::class => [ + [ + 'id' => Uuid::v7()->toRfc4122(), + 'type' => ToolCallMessage::class, + 'content' => 'Hello there', + 'contentAsBase64' => [], + 'toolsCalls' => [ + 'id' => '1', + 'name' => 'foo', + 'function' => [ + 'name' => 'foo', + 'arguments' => '{}', + ], + ], + ], + ]; + } +} diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 34eb978e..779d494c 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -58,5 +58,3 @@ CHANGELOG * Add response promises for async operations * Add InMemoryPlatform and InMemoryRawResult for testing Platform without external Providers calls * Add tool calling support for Ollama platform - -