Skip to content

Commit d39829d

Browse files
committed
feat(platform): meilisearch message bag
1 parent a23b6aa commit d39829d

File tree

13 files changed

+632
-1
lines changed

13 files changed

+632
-1
lines changed

docs/components/chat.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ with a ``Symfony\AI\Agent\AgentInterface`` and a ``Symfony\AI\Chat\MessageStoreI
3131

3232
$chat->submit(Message::ofUser('Hello'));
3333

34+
You can find more advanced usage in combination with an Agent using the store for long-term context:
35+
36+
* `Long-term context with Cache`_
37+
* `Long-term context with HttpFoundation session`_
38+
* `Long-term context with InMemory`_
39+
* `Long-term context with Meilisearch`_
40+
41+
Supported Message stores
42+
------------------------
43+
44+
* `Cache`_
45+
* `HttpFoundation session`_
46+
* `InMemory`_
47+
* `Meilisearch`_
3448

3549
Implementing a Bridge
3650
---------------------
@@ -105,3 +119,12 @@ store and ``bin/console ai:message-store:drop`` to clean up the message store:
105119
106120
$ php bin/console ai:message-store:setup symfonycon
107121
$ php bin/console ai:message-store:drop symfonycon
122+
123+
.. _`Long-term context with Cache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-cache.php
124+
.. _`Long-term context with HttpFoundation session`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-session.php
125+
.. _`Long-term context with InMemory`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat.php
126+
.. _`Long-term context with Meilisearch`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-meilisearch.php
127+
.. _`Cache`: https://symfony.com/doc/current/components/cache.html
128+
.. _`InMemory`: https://www.php.net/manual/en/language.types.array.php
129+
.. _`HttpFoundation session`: https://developers.cloudflare.com/vectorize/
130+
.. _`Meilisearch`: https://www.meilisearch.com/
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore;
14+
use Symfony\AI\Chat\Chat;
15+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
16+
use Symfony\AI\Platform\Message\Message;
17+
use Symfony\AI\Platform\Message\MessageBag;
18+
19+
require_once dirname(__DIR__).'/bootstrap.php';
20+
21+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
22+
23+
$store = new MessageStore(http_client(), env('MEILISEARCH_HOST'), env('MEILISEARCH_API_KEY'), 'symfony');
24+
$store->setup();
25+
26+
$agent = new Agent($platform, 'gpt-4o-mini');
27+
$chat = new Chat($agent, $store);
28+
29+
$messages = new MessageBag(
30+
Message::forSystem('You are a helpful assistant. You only answer with short sentences.'),
31+
);
32+
33+
$chat->initiate($messages);
34+
$chat->submit(Message::ofUser('My name is Christopher.'));
35+
$message = $chat->submit(Message::ofUser('What is my name?'));
36+
37+
echo $message->content.\PHP_EOL;

examples/commands/message-stores.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore;
1515
use Symfony\AI\Chat\Bridge\Local\CacheStore;
1616
use Symfony\AI\Chat\Bridge\Local\InMemoryStore;
17+
use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore as MeilisearchMessageStore;
1718
use Symfony\AI\Chat\Command\DropStoreCommand;
1819
use Symfony\AI\Chat\Command\SetupStoreCommand;
1920
use Symfony\Component\Cache\Adapter\ArrayAdapter;
@@ -28,6 +29,12 @@
2829

2930
$factories = [
3031
'cache' => static fn (): CacheStore => new CacheStore(new ArrayAdapter(), cacheKey: 'symfony'),
32+
'meilisearch' => static fn (): MeilisearchMessageStore => new MeilisearchMessageStore(
33+
http_client(),
34+
env('MEILISEARCH_HOST'),
35+
env('MEILISEARCH_API_KEY'),
36+
'symfony',
37+
),
3138
'memory' => static fn (): InMemoryStore => new InMemoryStore('symfony'),
3239
'session' => static function (): SessionStore {
3340
$request = Request::create('/');

examples/misc/persistent-chat-memory.php

Whitespace-only changes.

src/ai-bundle/config/options.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,16 @@
716716
->end()
717717
->end()
718718
->end()
719+
->arrayNode('meilisearch')
720+
->useAttributeAsKey('name')
721+
->arrayPrototype()
722+
->children()
723+
->stringNode('endpoint')->cannotBeEmpty()->end()
724+
->stringNode('api_key')->cannotBeEmpty()->end()
725+
->stringNode('index_name')->cannotBeEmpty()->end()
726+
->end()
727+
->end()
728+
->end()
719729
->arrayNode('session')
720730
->useAttributeAsKey('name')
721731
->arrayPrototype()

src/ai-bundle/src/AiBundle.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use Symfony\AI\AiBundle\Profiler\TraceableToolbox;
3636
use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool;
3737
use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore;
38+
use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore as MeilisearchMessageStore;
3839
use Symfony\AI\Chat\MessageStoreInterface;
3940
use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory as AnthropicPlatformFactory;
4041
use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAiPlatformFactory;
@@ -1310,6 +1311,23 @@ private function processMessageStoreConfig(string $type, array $messageStores, C
13101311
}
13111312
}
13121313

1314+
if ('meilisearch' === $type) {
1315+
foreach ($messageStores as $name => $messageStore) {
1316+
$definition = new Definition(MeilisearchMessageStore::class);
1317+
$definition
1318+
->setArguments([
1319+
$messageStore['endpoint'],
1320+
$messageStore['api_key'],
1321+
$messageStore['index_name'],
1322+
])
1323+
->addTag('ai.message_store');
1324+
1325+
$container->setDefinition('ai.message_store.'.$type.'.'.$name, $definition);
1326+
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name);
1327+
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name);
1328+
}
1329+
}
1330+
13131331
if ('session' === $type) {
13141332
foreach ($messageStores as $name => $messageStore) {
13151333
$definition = new Definition(SessionStore::class);

src/ai-bundle/tests/DependencyInjection/AiBundleTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3025,6 +3025,13 @@ private function getFullConfig(): array
30253025
'identifier' => '_memory',
30263026
],
30273027
],
3028+
'meilisearch' => [
3029+
'my_meilisearch_store' => [
3030+
'endpoint' => 'http://127.0.0.1:7700',
3031+
'api_key' => 'foo',
3032+
'index_name' => 'test',
3033+
],
3034+
],
30283035
'session' => [
30293036
'my_session_message_store' => [
30303037
'identifier' => 'session',

src/chat/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ https://github.com/symfony/ai to create issues or submit pull requests.
1919

2020
## Resources
2121

22-
- [Documentation](doc/index.rst)
22+
- [Documentation](../../docs/components/chat.rst)
2323
- [Report issues](https://github.com/symfony/ai/issues) and
2424
[send Pull Requests](https://github.com/symfony/ai/pulls)
2525
in the [main Symfony AI repository](https://github.com/symfony/ai)
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Chat\Bridge\Meilisearch;
13+
14+
use Symfony\AI\Chat\Exception\InvalidArgumentException;
15+
use Symfony\AI\Chat\Exception\LogicException;
16+
use Symfony\AI\Chat\ManagedStoreInterface;
17+
use Symfony\AI\Chat\MessageStoreInterface;
18+
use Symfony\AI\Platform\Message\AssistantMessage;
19+
use Symfony\AI\Platform\Message\Content\Audio;
20+
use Symfony\AI\Platform\Message\Content\ContentInterface;
21+
use Symfony\AI\Platform\Message\Content\DocumentUrl;
22+
use Symfony\AI\Platform\Message\Content\File;
23+
use Symfony\AI\Platform\Message\Content\Image;
24+
use Symfony\AI\Platform\Message\Content\ImageUrl;
25+
use Symfony\AI\Platform\Message\Content\Text;
26+
use Symfony\AI\Platform\Message\MessageBag;
27+
use Symfony\AI\Platform\Message\MessageInterface;
28+
use Symfony\AI\Platform\Message\SystemMessage;
29+
use Symfony\AI\Platform\Message\ToolCallMessage;
30+
use Symfony\AI\Platform\Message\UserMessage;
31+
use Symfony\AI\Platform\Result\ToolCall;
32+
use Symfony\Contracts\HttpClient\HttpClientInterface;
33+
34+
/**
35+
* @author Guillaume Loulier <[email protected]>
36+
*/
37+
final readonly class MessageStore implements ManagedStoreInterface, MessageStoreInterface
38+
{
39+
public function __construct(
40+
private HttpClientInterface $httpClient,
41+
private string $endpointUrl,
42+
#[\SensitiveParameter] private string $apiKey,
43+
private string $indexName,
44+
) {
45+
}
46+
47+
public function setup(array $options = []): void
48+
{
49+
if ([] !== $options) {
50+
throw new InvalidArgumentException('No supported options.');
51+
}
52+
53+
$this->request('POST', 'indexes', [
54+
'uid' => $this->indexName,
55+
'primaryKey' => 'id',
56+
]);
57+
}
58+
59+
public function save(MessageBag $messages): void
60+
{
61+
$messages = $messages->getMessages();
62+
63+
$this->request('PUT', \sprintf('indexes/%s/documents', $this->indexName), array_map(
64+
$this->convertToIndexableArray(...),
65+
$messages,
66+
));
67+
}
68+
69+
public function load(): MessageBag
70+
{
71+
$messages = $this->request('POST', \sprintf('indexes/%s/documents/fetch', $this->indexName));
72+
73+
return new MessageBag(...array_map($this->convertToMessage(...), $messages['results']));
74+
}
75+
76+
public function clear(): void
77+
{
78+
$this->request('DELETE', \sprintf('indexes/%s/documents', $this->indexName));
79+
}
80+
81+
public function drop(): void
82+
{
83+
$this->request('DELETE', \sprintf('indexes/%s', $this->indexName));
84+
}
85+
86+
/**
87+
* @param array<string, mixed>|list<array<string, mixed>> $payload
88+
*
89+
* @return array<string, mixed>
90+
*/
91+
private function request(string $method, string $endpoint, array $payload = []): array
92+
{
93+
$result = $this->httpClient->request($method, \sprintf('%s/%s', $this->endpointUrl, $endpoint), [
94+
'headers' => [
95+
'Authorization' => \sprintf('Bearer %s', $this->apiKey),
96+
],
97+
'json' => [] !== $payload ? $payload : new \stdClass(),
98+
]);
99+
100+
return $result->toArray();
101+
}
102+
103+
/**
104+
* @return array<string, mixed>
105+
*/
106+
private function convertToIndexableArray(MessageInterface $message): array
107+
{
108+
$toolsCalls = [];
109+
110+
if ($message instanceof AssistantMessage && $message->hasToolCalls()) {
111+
$toolsCalls = array_map(
112+
static fn (ToolCall $toolCall): array => $toolCall->jsonSerialize(),
113+
$message->toolCalls,
114+
);
115+
}
116+
117+
if ($message instanceof ToolCallMessage) {
118+
$toolsCalls = $message->toolCall->jsonSerialize();
119+
}
120+
121+
return [
122+
'id' => $message->getId()->toRfc4122(),
123+
'type' => $message::class,
124+
'content' => ($message instanceof SystemMessage || $message instanceof AssistantMessage || $message instanceof ToolCallMessage) ? $message->content : '',
125+
'contentAsBase64' => ($message instanceof UserMessage && [] !== $message->content) ? array_map(
126+
static fn (ContentInterface $content) => [
127+
'type' => $content::class,
128+
'content' => match ($content::class) {
129+
Text::class => $content->text,
130+
File::class,
131+
Image::class,
132+
Audio::class => $content->asBase64(),
133+
ImageUrl::class,
134+
DocumentUrl::class => $content->url,
135+
default => throw new LogicException(\sprintf('Unknown content type "%s".', $content::class)),
136+
},
137+
],
138+
$message->content,
139+
) : [],
140+
'toolsCalls' => $toolsCalls,
141+
];
142+
}
143+
144+
/**
145+
* @param array<string, mixed> $payload
146+
*/
147+
private function convertToMessage(array $payload): MessageInterface
148+
{
149+
$type = $payload['type'];
150+
$content = $payload['content'] ?? '';
151+
$contentAsBase64 = $payload['contentAsBase64'] ?? [];
152+
153+
return match ($type) {
154+
SystemMessage::class => new SystemMessage($content),
155+
AssistantMessage::class => new AssistantMessage($content, array_map(
156+
static fn (array $toolsCall): ToolCall => new ToolCall(
157+
$toolsCall['id'],
158+
$toolsCall['function']['name'],
159+
json_decode($toolsCall['function']['arguments'], true)
160+
),
161+
$payload['toolsCalls'],
162+
)),
163+
UserMessage::class => new UserMessage(...array_map(
164+
static fn (array $contentAsBase64) => \in_array($contentAsBase64['type'], [File::class, Image::class, Audio::class], true)
165+
? $contentAsBase64['type']::fromDataUrl($contentAsBase64['content'])
166+
: new $contentAsBase64['type']($contentAsBase64['content']),
167+
$contentAsBase64,
168+
)),
169+
ToolCallMessage::class => new ToolCallMessage(
170+
new ToolCall(
171+
$payload['toolsCalls']['id'],
172+
$payload['toolsCalls']['function']['name'],
173+
json_decode($payload['toolsCalls']['function']['arguments'], true)
174+
),
175+
$content
176+
),
177+
default => throw new LogicException(\sprintf('Unknown message type "%s".', $type)),
178+
};
179+
}
180+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Chat\Exception;
13+
14+
use Symfony\AI\Chat\Exception\ExceptionInterface;
15+
16+
/**
17+
* @author Oskar Stark <[email protected]>
18+
*/
19+
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
20+
{
21+
}

0 commit comments

Comments
 (0)