diff --git a/src/Command/OllamaEmbedding.php b/src/Command/OllamaChat.php similarity index 55% rename from src/Command/OllamaEmbedding.php rename to src/Command/OllamaChat.php index 9ec87ea66ace41231f5b655f94c6bcfa53cb7573..656210f4772228125c39fe5b8bf6fd8f14ac0df6 100644 --- a/src/Command/OllamaEmbedding.php +++ b/src/Command/OllamaChat.php @@ -5,26 +5,25 @@ declare(strict_types=1); namespace Distantmagic\Resonance\Command; use Distantmagic\Resonance\Attribute\ConsoleCommand; -use Distantmagic\Resonance\Command; use Distantmagic\Resonance\CoroutineCommand; -use Distantmagic\Resonance\JsonSerializer; +use Distantmagic\Resonance\OllamaChatSession; use Distantmagic\Resonance\OllamaClient; -use Distantmagic\Resonance\OllamaEmbeddingRequest; use Distantmagic\Resonance\SwooleConfiguration; -use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; #[ConsoleCommand( - name: 'ollama:embedding', - description: 'Generate LLM embedding' + name: 'ollama:chat', + description: 'Chat with LLM model through Ollama' )] -final class OllamaEmbedding extends CoroutineCommand +final class OllamaChat extends CoroutineCommand { public function __construct( - private JsonSerializer $jsonSerializer, - private OllamaClient $ollamaClient, + protected OllamaClient $ollamaClient, SwooleConfiguration $swooleConfiguration, ) { parent::__construct($swooleConfiguration); @@ -32,7 +31,6 @@ final class OllamaEmbedding extends CoroutineCommand protected function configure(): void { - $this->addArgument('prompt', InputArgument::REQUIRED); $this->addOption( default: 'mistral', mode: InputOption::VALUE_REQUIRED, @@ -48,25 +46,25 @@ final class OllamaEmbedding extends CoroutineCommand $model = $input->getOption('model'); /** - * @var string $prompt + * @var QuestionHelper $helper */ - $prompt = $input->getArgument('prompt'); + $helper = $this->getHelper('question'); + $userInputQuestion = new Question('> '); - $embeddingRequest = new OllamaEmbeddingRequest( + $chatSession = new OllamaChatSession( model: $model, - prompt: $prompt, + ollamaClient: $this->ollamaClient, ); - $embeddingResponse = $this - ->ollamaClient - ->generateEmbedding($embeddingRequest) - ; + while (true) { + $userMessageContent = $helper->ask($input, $output, $userInputQuestion); - $output->writeln( - $this - ->jsonSerializer - ->serialize($embeddingResponse) - ); + foreach ($chatSession->respond($userMessageContent) as $value) { + $output->write((string) $value); + } + + $output->writeln(''); + } return Command::SUCCESS; } diff --git a/src/Command/OllamaCompletion.php b/src/Command/OllamaGenerate.php similarity index 64% rename from src/Command/OllamaCompletion.php rename to src/Command/OllamaGenerate.php index 0d7e5e6d932b13e4c4b3a3264014b6621a0e0727..9cbc8bb10776f23b9500c011b862dd44361b8057 100644 --- a/src/Command/OllamaCompletion.php +++ b/src/Command/OllamaGenerate.php @@ -4,25 +4,20 @@ declare(strict_types=1); namespace Distantmagic\Resonance\Command; -use Distantmagic\Resonance\Attribute\ConsoleCommand; -use Distantmagic\Resonance\Command; use Distantmagic\Resonance\CoroutineCommand; use Distantmagic\Resonance\OllamaClient; -use Distantmagic\Resonance\OllamaCompletionRequest; use Distantmagic\Resonance\SwooleConfiguration; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -#[ConsoleCommand( - name: 'ollama:completion', - description: 'Generate LLM completion' -)] -final class OllamaCompletion extends CoroutineCommand +abstract class OllamaGenerate extends CoroutineCommand { + abstract protected function executeOllamaCommand(InputInterface $input, OutputInterface $output, string $model, string $prompt): int; + public function __construct( - private OllamaClient $ollamaClient, + protected OllamaClient $ollamaClient, SwooleConfiguration $swooleConfiguration, ) { parent::__construct($swooleConfiguration); @@ -50,15 +45,6 @@ final class OllamaCompletion extends CoroutineCommand */ $prompt = $input->getArgument('prompt'); - $completionRequest = new OllamaCompletionRequest( - model: $model, - prompt: $prompt, - ); - - foreach ($this->ollamaClient->generateCompletion($completionRequest) as $token) { - $output->write($token); - } - - return Command::SUCCESS; + return $this->executeOllamaCommand($input, $output, $model, $prompt); } } diff --git a/src/Command/OllamaGenerate/Completion.php b/src/Command/OllamaGenerate/Completion.php new file mode 100644 index 0000000000000000000000000000000000000000..9d9d523b0b20146a572e250914682f846a1e10a7 --- /dev/null +++ b/src/Command/OllamaGenerate/Completion.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Command\OllamaGenerate; + +use Distantmagic\Resonance\Attribute\ConsoleCommand; +use Distantmagic\Resonance\Command; +use Distantmagic\Resonance\Command\OllamaGenerate; +use Distantmagic\Resonance\OllamaCompletionRequest; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[ConsoleCommand( + name: 'ollama:completion', + description: 'Generate LLM completion' +)] +final class Completion extends OllamaGenerate +{ + protected function executeOllamaCommand(InputInterface $input, OutputInterface $output, string $model, string $prompt): int + { + $completionRequest = new OllamaCompletionRequest( + model: $model, + prompt: $prompt, + ); + + foreach ($this->ollamaClient->generateCompletion($completionRequest) as $token) { + $output->write((string) $token); + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/OllamaGenerate/Embedding.php b/src/Command/OllamaGenerate/Embedding.php new file mode 100644 index 0000000000000000000000000000000000000000..9c5ec24e0393b0b3a5f8cbc51096d1f4024c07de --- /dev/null +++ b/src/Command/OllamaGenerate/Embedding.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Command\OllamaGenerate; + +use Distantmagic\Resonance\Attribute\ConsoleCommand; +use Distantmagic\Resonance\Command; +use Distantmagic\Resonance\Command\OllamaGenerate; +use Distantmagic\Resonance\JsonSerializer; +use Distantmagic\Resonance\OllamaClient; +use Distantmagic\Resonance\OllamaEmbeddingRequest; +use Distantmagic\Resonance\SwooleConfiguration; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[ConsoleCommand( + name: 'ollama:embedding', + description: 'Generate LLM embedding' +)] +final class Embedding extends OllamaGenerate +{ + public function __construct( + private JsonSerializer $jsonSerializer, + OllamaClient $ollamaClient, + SwooleConfiguration $swooleConfiguration, + ) { + parent::__construct($ollamaClient, $swooleConfiguration); + } + + protected function executeOllamaCommand(InputInterface $input, OutputInterface $output, string $model, string $prompt): int + { + $embeddingRequest = new OllamaEmbeddingRequest( + model: $model, + prompt: $prompt, + ); + + $embeddingResponse = $this + ->ollamaClient + ->generateEmbedding($embeddingRequest) + ; + + $output->writeln( + $this + ->jsonSerializer + ->serialize($embeddingResponse) + ); + + return Command::SUCCESS; + } +} diff --git a/src/OllamaChatMessage.php b/src/OllamaChatMessage.php new file mode 100644 index 0000000000000000000000000000000000000000..a50091df4fb7d6b3dca77af61b212f209a7c8811 --- /dev/null +++ b/src/OllamaChatMessage.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use JsonSerializable; +use Stringable; + +readonly class OllamaChatMessage implements JsonSerializable, Stringable +{ + public function __construct( + public string $content, + public OllamaChatRole $role, + ) {} + + public function __toString(): string + { + return $this->content; + } + + public function jsonSerialize(): array + { + return [ + 'content' => $this->content, + 'role' => $this->role->value, + ]; + } +} diff --git a/src/OllamaChatRequest.php b/src/OllamaChatRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..c87c64eb5e86f2c77b95eb697e8fd54a01136970 --- /dev/null +++ b/src/OllamaChatRequest.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use JsonSerializable; + +readonly class OllamaChatRequest implements JsonSerializable +{ + /** + * @param array<OllamaChatMessage> $messages + */ + public function __construct( + public string $model, + public array $messages, + public OllamaRequestOptions $options = new OllamaRequestOptions(), + ) {} + + public function jsonSerialize(): array + { + return [ + 'model' => $this->model, + 'messages' => $this->messages, + 'options' => $this->options, + 'raw' => true, + 'stream' => true, + ]; + } +} diff --git a/src/OllamaChatRole.php b/src/OllamaChatRole.php new file mode 100644 index 0000000000000000000000000000000000000000..0a66628a5d3c0c6245891814c391387b0590eb9f --- /dev/null +++ b/src/OllamaChatRole.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +enum OllamaChatRole: string +{ + case Assistant = 'assistant'; + case System = 'system'; + case User = 'user'; +} diff --git a/src/OllamaChatSession.php b/src/OllamaChatSession.php new file mode 100644 index 0000000000000000000000000000000000000000..2ff877d706ff477fe8a31ad9645035ecf250c96b --- /dev/null +++ b/src/OllamaChatSession.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use Ds\Set; +use Generator; + +readonly class OllamaChatSession +{ + /** + * @var Set<OllamaChatMessage> + */ + private Set $messages; + + public function __construct( + public string $model, + public OllamaClient $ollamaClient, + ) { + $this->messages = new Set(); + } + + /** + * @return Generator<OllamaChatToken> + */ + public function respond(string $userMessageContent): Generator + { + $this + ->messages + ->add(new OllamaChatMessage($userMessageContent, OllamaChatRole::User)) + ; + + $chatRequest = new OllamaChatRequest( + model: $this->model, + messages: $this->messages->toArray(), + ); + + yield from $this->ollamaClient->generateChatCompletion($chatRequest); + } +} diff --git a/src/OllamaChatToken.php b/src/OllamaChatToken.php new file mode 100644 index 0000000000000000000000000000000000000000..9e77141a0516c9dfc2be26394fd67ff9b47cf67f --- /dev/null +++ b/src/OllamaChatToken.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use DateTimeImmutable; +use Stringable; + +readonly class OllamaChatToken implements Stringable +{ + public function __construct( + public DateTimeImmutable $createdAt, + public OllamaChatMessage $message, + ) {} + + public function __toString(): string + { + return (string) $this->message; + } +} diff --git a/src/OllamaClient.php b/src/OllamaClient.php index 28a2fb85207e6990d7571022882cd10fbf0642d6..2d351aa0f4c33307ec27ed3920db1ccbe5a3ed45 100644 --- a/src/OllamaClient.php +++ b/src/OllamaClient.php @@ -5,8 +5,11 @@ declare(strict_types=1); namespace Distantmagic\Resonance; use CurlHandle; +use DateTimeImmutable; use Distantmagic\Resonance\Attribute\Singleton; use Generator; +use JsonSerializable; +use Psr\Log\LoggerInterface; use RuntimeException; use Swoole\Coroutine\Channel; @@ -17,6 +20,7 @@ readonly class OllamaClient public function __construct( private JsonSerializer $jsonSerializer, + private LoggerInterface $logger, private OllamaLinkBuilder $ollamaLinkBuilder, ) { $this->ch = curl_init(); @@ -33,53 +37,56 @@ readonly class OllamaClient } /** - * @return Generator<string> + * @return Generator<OllamaChatToken> */ - public function generateCompletion(OllamaCompletionRequest $request): Generator + public function generateChatCompletion(OllamaChatRequest $request): Generator { - $channel = new Channel(1); - $requestData = json_encode($request); - - $cid = go(function () use ($channel, $requestData) { - try { - curl_setopt($this->ch, CURLOPT_URL, $this->ollamaLinkBuilder->build('/api/generate')); - curl_setopt($this->ch, CURLOPT_POSTFIELDS, $requestData); - curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, false); - curl_setopt($this->ch, CURLOPT_WRITEFUNCTION, function (CurlHandle $ch, string $data) use ($channel) { - if (!empty($data)) { - $channel->push( - $this - ->jsonSerializer - ->unserialize($data) - ); - } - - return strlen($data); - }); - - if (!curl_exec($this->ch)) { - throw new CurlException($this->ch); - } + $channel = $this->streamJson($request, '/api/chat'); - $this->assertStatusCode(200); - } finally { - curl_setopt($this->ch, CURLOPT_WRITEFUNCTION, null); + /** + * @var SwooleChannelIterator<object{ error: string }|object{ + * created_at: string, + * message: object{ + * content: string, + * role: string, + * }, + * response: string, + * }> + */ + $swooleChannelIterator = new SwooleChannelIterator($channel); - $channel->close(); + foreach ($swooleChannelIterator as $data) { + if (isset($data->error)) { + $this->logger->error($data->error); + } else { + yield new OllamaChatToken( + createdAt: new DateTimeImmutable($data->created_at), + message: new OllamaChatMessage( + content: $data->message->content, + role: OllamaChatRole::from($data->message->role), + ) + ); } - }); - - if (!is_int($cid)) { - throw new RuntimeException('Unable to start a coroutine'); } + } + + /** + * @return Generator<OllamaCompletionToken> + */ + public function generateCompletion(OllamaCompletionRequest $request): Generator + { + $channel = $this->streamJson($request, '/api/generate'); /** - * @var SwooleChannelIterator<object{ response: string }> + * @var SwooleChannelIterator<object{ created_at: string, response: string }> */ $swooleChannelIterator = new SwooleChannelIterator($channel); foreach ($swooleChannelIterator as $token) { - yield $token->response; + yield new OllamaCompletionToken( + createdAt: new DateTimeImmutable($token->created_at), + response: $token->response, + ); } } @@ -129,4 +136,49 @@ readonly class OllamaClient $statusCode, )); } + + private function streamJson(JsonSerializable $request, string $path): Channel + { + $channel = new Channel(1); + $requestData = json_encode($request); + + $cid = go(function () use ($channel, $path, $requestData) { + try { + curl_setopt($this->ch, CURLOPT_URL, $this->ollamaLinkBuilder->build($path)); + curl_setopt($this->ch, CURLOPT_POSTFIELDS, $requestData); + curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, false); + curl_setopt($this->ch, CURLOPT_WRITEFUNCTION, function (CurlHandle $ch, string $data) use ($channel) { + $dataChunks = explode("\n", $data); + + foreach ($dataChunks as $dataChunk) { + if (!empty($dataChunk)) { + $channel->push( + $this + ->jsonSerializer + ->unserialize($dataChunk) + ); + } + } + + return strlen($data); + }); + + if (!curl_exec($this->ch)) { + throw new CurlException($this->ch); + } + + $this->assertStatusCode(200); + } finally { + curl_setopt($this->ch, CURLOPT_WRITEFUNCTION, null); + + $channel->close(); + } + }); + + if (!is_int($cid)) { + throw new RuntimeException('Unable to start a coroutine'); + } + + return $channel; + } } diff --git a/src/OllamaCompletionToken.php b/src/OllamaCompletionToken.php new file mode 100644 index 0000000000000000000000000000000000000000..bb4947d21d85606b584779ef96a0934fd9568546 --- /dev/null +++ b/src/OllamaCompletionToken.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use DateTimeImmutable; +use Stringable; + +readonly class OllamaCompletionToken implements Stringable +{ + public function __construct( + public DateTimeImmutable $createdAt, + public string $response, + ) {} + + public function __toString(): string + { + return $this->response; + } +} diff --git a/src/OllamaRequestOptions.php b/src/OllamaRequestOptions.php index 0f8136076975daf355025334ad47e39ccb6a7e4f..24a5a62c10d4dd624283fdb4cc97d5d82432b638 100644 --- a/src/OllamaRequestOptions.php +++ b/src/OllamaRequestOptions.php @@ -9,7 +9,8 @@ use JsonSerializable; readonly class OllamaRequestOptions implements JsonSerializable { public function __construct( - public float $temperature = 0.8, + public float $numPredict = -1, + public float $temperature = 0.5, public OllamaRequestStopDelimiter $stopDelimiter = new OllamaRequestStopDelimiter(), ) {} @@ -17,6 +18,7 @@ readonly class OllamaRequestOptions implements JsonSerializable { $ret = []; + $ret['num_predict'] = $this->numPredict; $ret['stop'] = $this->stopDelimiter; $ret['temperature'] = $this->temperature;