Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

Message Splitting to allow multiple message implementations without stacking arguments #25

Merged
merged 8 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions examples/image-describer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use PhpLlm\LlmChain\Chain;
use PhpLlm\LlmChain\Message\Content\Image;
use PhpLlm\LlmChain\Message\Message;
use PhpLlm\LlmChain\Message\MessageBag;
use PhpLlm\LlmChain\OpenAI\Model\Gpt;
use PhpLlm\LlmChain\OpenAI\Model\Gpt\Version;
use PhpLlm\LlmChain\OpenAI\Runtime\OpenAI;
use Symfony\Component\HttpClient\HttpClient;

require_once dirname(__DIR__).'/vendor/autoload.php';

$runtime = new OpenAI(HttpClient::create(), getenv('OPENAI_API_KEY'));
$llm = new Gpt($runtime, Version::gpt4oMini());

$chain = new Chain($llm);
$messages = new MessageBag(
Message::forSystem('You are an image analyzer bot that helps identify the content of images.'),
Message::ofUser(
'Describe the images as a comedian would do it.',
new Image('https://upload.wikimedia.org/wikipedia/commons/thumb/3/31/Webysther_20160423_-_Elephpant.svg/350px-Webysther_20160423_-_Elephpant.svg.png'),
new Image('https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/African_Bush_Elephant.jpg/320px-African_Bush_Elephant.jpg'),
),
);
$response = $chain->call($messages);

echo $response.PHP_EOL;
53 changes: 53 additions & 0 deletions src/Message/AssistantMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Message;

use PhpLlm\LlmChain\Response\ToolCall;

final readonly class AssistantMessage implements MessageInterface
{
/**
* @param ?ToolCall[] $toolCalls
*/
public function __construct(
public ?string $content = null,
public ?array $toolCalls = null,
) {
}

public function getRole(): Role
{
return Role::Assistant;
}

public function hasToolCalls(): bool
{
return null !== $this->toolCalls && 0 !== \count($this->toolCalls);
}

/**
* @return array{
* role: Role::Assistant,
* content: ?string,
* tool_calls?: ToolCall[],
* }
*/
public function jsonSerialize(): array
{
$array = [
'role' => Role::Assistant,
];

if (null !== $this->content) {
$array['content'] = $this->content;
}

if ($this->hasToolCalls()) {
$array['tool_calls'] = $this->toolCalls;
}

return $array;
}
}
9 changes: 9 additions & 0 deletions src/Message/Content/ContentInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Message\Content;

interface ContentInterface extends \JsonSerializable
{
}
23 changes: 23 additions & 0 deletions src/Message/Content/Image.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Message\Content;

final readonly class Image implements ContentInterface
{
/**
* @param string $url An URL like "http://localhost:3000/my-image.png" or a data url like "data:image/png;base64,iVBOR[...]"
*/
public function __construct(public string $url)
{
}

/**
* @return array{type: 'image_url', image_url: array{url: string}}
*/
public function jsonSerialize(): array
{
return ['type' => 'image_url', 'image_url' => ['url' => $this->url]];
}
}
20 changes: 20 additions & 0 deletions src/Message/Content/Text.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Message\Content;

final readonly class Text implements ContentInterface
{
public function __construct(public string $text)
{
}

/**
* @return array{type: 'text', text: string}
*/
public function jsonSerialize(): array
{
return ['type' => 'text', 'text' => $this->text];
}
}
88 changes: 18 additions & 70 deletions src/Message/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,94 +4,42 @@

namespace PhpLlm\LlmChain\Message;

use PhpLlm\LlmChain\Message\Content\ContentInterface;
use PhpLlm\LlmChain\Message\Content\Text;
use PhpLlm\LlmChain\Response\ToolCall;

final readonly class Message implements \JsonSerializable
final readonly class Message
{
/**
* @param ?ToolCall[] $toolCalls
*/
public function __construct(
public ?string $content,
public Role $role,
public ?array $toolCalls = null,
) {
// Disabled by default, just a bridge to the specific messages
private function __construct()
{
}

public static function forSystem(string $content): self
public static function forSystem(string $content): SystemMessage
{
return new self($content, Role::System);
return new SystemMessage($content);
}

/**
* @param ?ToolCall[] $toolCalls
*/
public static function ofAssistant(?string $content = null, ?array $toolCalls = null): self
{
return new self($content, Role::Assistant, $toolCalls);
}

public static function ofUser(string $content): self
public static function ofAssistant(?string $content = null, ?array $toolCalls = null): AssistantMessage
{
return new self($content, Role::User);
return new AssistantMessage($content, $toolCalls);
}

public static function ofToolCall(ToolCall $toolCall, string $content): self
public static function ofUser(string|ContentInterface ...$content): UserMessage
{
return new self($content, Role::ToolCall, [$toolCall]);
}
$content = \array_map(
static fn (string|ContentInterface $entry) => \is_string($entry) ? new Text($entry) : $entry,
$content,
);

public function isSystem(): bool
{
return Role::System === $this->role;
return new UserMessage(...$content);
}

public function isAssistant(): bool
public static function ofToolCall(ToolCall $toolCall, string $content): ToolCallMessage
{
return Role::Assistant === $this->role;
}

public function isUser(): bool
{
return Role::User === $this->role;
}

public function isToolCall(): bool
{
return Role::ToolCall === $this->role;
}

public function hasToolCalls(): bool
{
return null !== $this->toolCalls && 0 !== count($this->toolCalls);
}

/**
* @return array{
* role: 'system'|'assistant'|'user'|'tool',
* content: ?string,
* tool_calls?: ToolCall[],
* tool_call_id?: string
* }
*/
public function jsonSerialize(): array
{
$array = [
'role' => $this->role->value,
];

if (null !== $this->content) {
$array['content'] = $this->content;
}

if ($this->hasToolCalls() && $this->isToolCall()) {
$array['tool_call_id'] = $this->toolCalls[0]->id;
}

if ($this->hasToolCalls() && $this->isAssistant()) {
$array['tool_calls'] = $this->toolCalls;
}

return $array;
return new ToolCallMessage($toolCall, $content);
}
}
19 changes: 11 additions & 8 deletions src/Message/MessageBag.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,27 @@
namespace PhpLlm\LlmChain\Message;

/**
* @template-extends \ArrayObject<int, Message>
* @template-extends \ArrayObject<int, MessageInterface>
*/
final class MessageBag extends \ArrayObject implements \JsonSerializable
{
public function __construct(Message ...$messages)
public function __construct(MessageInterface ...$messages)
{
parent::__construct(array_values($messages));
}

public function getSystemMessage(): ?Message
public function getSystemMessage(): ?SystemMessage
{
foreach ($this as $message) {
if (Role::System === $message->role) {
if ($message instanceof SystemMessage) {
return $message;
}
}

return null;
}

public function with(Message $message): self
public function with(MessageInterface $message): self
{
$messages = clone $this;
$messages->append($message);
Expand All @@ -45,13 +45,16 @@ public function withoutSystemMessage(): self
{
$messages = clone $this;
$messages->exchangeArray(
array_values(array_filter($messages->getArrayCopy(), fn (Message $message) => !$message->isSystem()))
array_values(array_filter(
$messages->getArrayCopy(),
static fn (MessageInterface $message) => !$message instanceof SystemMessage,
))
);

return $messages;
}

public function prepend(Message $message): self
public function prepend(MessageInterface $message): self
{
$messages = clone $this;
$messages->exchangeArray(array_merge([$message], $messages->getArrayCopy()));
Expand All @@ -60,7 +63,7 @@ public function prepend(Message $message): self
}

/**
* @return Message[]
* @return MessageInterface[]
*/
public function jsonSerialize(): array
{
Expand Down
10 changes: 10 additions & 0 deletions src/Message/MessageInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Message;

interface MessageInterface extends \JsonSerializable
{
public function getRole(): Role;
}
31 changes: 31 additions & 0 deletions src/Message/SystemMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Message;

final readonly class SystemMessage implements MessageInterface
{
public function __construct(public string $content)
{
}

public function getRole(): Role
{
return Role::System;
}

/**
* @return array{
* role: Role::System,
* content: string
* }
*/
public function jsonSerialize(): array
{
return [
'role' => Role::System,
'content' => $this->content,
];
}
}
37 changes: 37 additions & 0 deletions src/Message/ToolCallMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Message;

use PhpLlm\LlmChain\Response\ToolCall;

final readonly class ToolCallMessage implements MessageInterface
{
public function __construct(
public ToolCall $toolCall,
public string $content,
) {
}

public function getRole(): Role
{
return Role::ToolCall;
}

/**
* @return array{
* role: Role::ToolCall,
* content: string,
* tool_call_id: string,
* }
*/
public function jsonSerialize(): array
{
return [
'role' => Role::ToolCall,
'content' => $this->content,
'tool_call_id' => $this->toolCall->id,
];
}
}
Loading