add remote component to ask questions to the bot

The endpoint allows to override model and language settings on demand.
This commit is contained in:
Andreas Gohr
2025-03-12 10:26:18 +01:00
parent 3272c143f5
commit 42b2c6e864
18 changed files with 149 additions and 36 deletions

View File

@ -59,12 +59,12 @@ abstract class AbstractModel implements ModelInterface
$reflect = new \ReflectionClass($this);
$json = dirname($reflect->getFileName()) . '/models.json';
if (!file_exists($json)) {
throw new \Exception('Model info file not found at ' . $json);
throw new \Exception('Model info file not found at ' . $json, 2001);
}
try {
$modelinfos = json_decode(file_get_contents($json), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \Exception('Failed to parse model info file: ' . $e->getMessage(), $e->getCode(), $e);
throw new \Exception('Failed to parse model info file: ' . $e->getMessage(), 2002, $e);
}
$this->modelFullName = basename(dirname($reflect->getFileName()) . ' ' . $name);
@ -249,7 +249,7 @@ abstract class AbstractModel implements ModelInterface
$json = json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
} catch (\JsonException $e) {
$this->timeUsed += $this->requestStart - microtime(true);
throw new \Exception('Failed to encode JSON for API:' . $e->getMessage(), $e->getCode(), $e);
throw new \Exception('Failed to encode JSON for API:' . $e->getMessage(), 2003, $e);
}
if ($this->debug) {
@ -266,7 +266,7 @@ abstract class AbstractModel implements ModelInterface
return $this->sendAPIRequest($method, $url, $data, $retry + 1);
}
$this->timeUsed += microtime(true) - $this->requestStart;
throw new \Exception('API returned no response. ' . $this->http->error);
throw new \Exception('API returned no response. ' . $this->http->error, 2004);
}
if ($this->debug) {
@ -280,7 +280,7 @@ abstract class AbstractModel implements ModelInterface
$result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$this->timeUsed += microtime(true) - $this->requestStart;
throw new \Exception('API returned invalid JSON: ' . $response, 0, $e);
throw new \Exception('API returned invalid JSON: ' . $response, 2005, $e);
}
// parse the response, retry on error

View File

@ -13,7 +13,7 @@ class ChatModel extends AbstractModel implements ChatInterface
parent::__construct($name, $config);
if (empty($config['anthropic_apikey'])) {
throw new \Exception('Anthropic API key not configured');
throw new \Exception('Anthropic API key not configured', 3001);
}
$this->http->headers['x-api-key'] = $config['anthropic_apikey'];
@ -73,7 +73,7 @@ class ChatModel extends AbstractModel implements ChatInterface
}
if (isset($response['error'])) {
throw new \Exception('Anthropic API error: ' . $response['error']['message']);
throw new \Exception('Anthropic API error: ' . $response['error']['message'], 3002);
}
return $response;

View File

@ -14,7 +14,7 @@ abstract class AbstractGeminiModel extends AbstractModel
public function __construct(string $name, array $config)
{
if (empty($config['gemini_apikey'])) {
throw new \Exception('Gemini API key not configured');
throw new \Exception('Gemini API key not configured', 3001);
}
$this->apikey = $config['gemini_apikey'];
@ -32,7 +32,7 @@ abstract class AbstractGeminiModel extends AbstractModel
);
$result = $this->sendAPIRequest('GET', $url, '');
if(!$result) {
throw new \Exception('Failed to load model info for '.$this->modelFullName);
throw new \Exception('Failed to load model info for '.$this->modelFullName, 3003);
}
$info = parent::loadUnknownModelInfo();
@ -71,7 +71,7 @@ abstract class AbstractGeminiModel extends AbstractModel
}
if (isset($response['error'])) {
throw new \Exception('Gemini API error: ' . $response['error']['message']);
throw new \Exception('Gemini API error: ' . $response['error']['message'], 3002);
}
return $response;

View File

@ -13,7 +13,7 @@ class ChatModel extends AbstractModel implements ChatInterface
parent::__construct($name, $config);
if (empty($config['groq_apikey'])) {
throw new \Exception('Groq API key not configured');
throw new \Exception('Groq API key not configured', 3001);
}
$this->http->headers['Authorization'] = 'Bearer ' . $config['groq_apikey'];
@ -57,7 +57,7 @@ class ChatModel extends AbstractModel implements ChatInterface
}
if (isset($response['error'])) {
throw new \Exception('Groq API error: ' . $response['error']['message']);
throw new \Exception('Groq API error: ' . $response['error']['message'], 3002);
}
return $response;

View File

@ -16,7 +16,7 @@ abstract class AbstractMistralModel extends AbstractModel
{
parent::__construct($name, $config);
if (empty($config['mistral_apikey'])) {
throw new \Exception('Mistral API key not configured');
throw new \Exception('Mistral API key not configured', 3001);
}
$this->http->headers['Authorization'] = 'Bearer ' . $config['mistral_apikey'];
}
@ -44,7 +44,7 @@ abstract class AbstractMistralModel extends AbstractModel
}
if (isset($response['object']) && $response['object'] === 'error') {
throw new \Exception('Mistral API error: ' . $response['message']);
throw new \Exception('Mistral API error: ' . $response['message'], 3002);
}
return $response;

View File

@ -19,7 +19,7 @@ abstract class AbstractOllama extends AbstractModel
parent::__construct($name, $config);
$this->apiurl = rtrim($config['ollama_baseurl'] ?? '', '/');
if ($this->apiurl === '') {
throw new \Exception('Ollama base URL not configured');
throw new \Exception('Ollama base URL not configured', 3001);
}
}
@ -67,7 +67,7 @@ abstract class AbstractOllama extends AbstractModel
if (isset($response['error'])) {
$error = is_array($response['error']) ? $response['error']['message'] : $response['error'];
throw new \Exception('Ollama API error: ' . $error);
throw new \Exception('Ollama API error: ' . $error, 3002);
}
return $response;

View File

@ -17,7 +17,7 @@ abstract class AbstractOpenAIModel extends AbstractModel
parent::__construct($name, $config);
if (empty($config['openai_apikey'])) {
throw new \Exception('OpenAI API key not configured');
throw new \Exception('OpenAI API key not configured', 3001);
}
$openAIKey = $config['openai_apikey'];
@ -52,7 +52,7 @@ abstract class AbstractOpenAIModel extends AbstractModel
}
if (isset($response['error'])) {
throw new \Exception('OpenAI API error: ' . $response['error']['message']);
throw new \Exception('OpenAI API error: ' . $response['error']['message'], 3002);
}
return $response;

View File

@ -13,7 +13,7 @@ class ChatModel extends AbstractModel implements ChatInterface
parent::__construct($name, $config);
if (empty($config['reka_apikey'])) {
throw new \Exception('Reka API key not configured');
throw new \Exception('Reka API key not configured', 3001);
}
$this->http->headers['x-api-key'] = $config['reka_apikey'];
@ -67,9 +67,9 @@ class ChatModel extends AbstractModel implements ChatInterface
{
if (((int) $this->http->status) !== 200) {
if (isset($response['detail'])) {
throw new \Exception('Reka API error: ' . $response['detail']);
throw new \Exception('Reka API error: ' . $response['detail'], 3002);
} else {
throw new \Exception('Reka API error: ' . $this->http->status . ' ' . $this->http->error);
throw new \Exception('Reka API error: ' . $this->http->status . ' ' . $this->http->error, 3002);
}
}

View File

@ -13,7 +13,7 @@ class EmbeddingModel extends AbstractModel implements EmbeddingInterface
parent::__construct($name, $config);
if (empty($config['voyageai_apikey'])) {
throw new \Exception('Voyage AI API key not configured');
throw new \Exception('Voyage AI API key not configured', 3001);
}
$this->http->headers['Authorization'] = 'Bearer ' . $config['voyageai_apikey'];
@ -53,7 +53,7 @@ class EmbeddingModel extends AbstractModel implements EmbeddingInterface
}
if (isset($response['error'])) {
throw new \Exception('OpenAI API error: ' . $response['error']['message']);
throw new \Exception('OpenAI API error: ' . $response['error']['message'], 3002);
}
return $response;

View File

@ -156,17 +156,17 @@ class ModelFactory
$class = $prefix . $namespace . '\\' . $cname;
if (!class_exists($class)) {
throw new \Exception("No $cname found for $namespace");
throw new \Exception("No $cname found for $namespace", 1001);
}
try {
$instance = new $class($model, $this->config);
} catch (\Exception $e) {
throw new \Exception("Failed to initialize $cname for $namespace: " . $e->getMessage(), 0, $e);
throw new \Exception("Failed to initialize $cname for $namespace: " . $e->getMessage(), 1002, $e);
}
if (!($instance instanceof $interface)) {
throw new \Exception("$cname for $namespace does not implement $interface");
throw new \Exception("$cname for $namespace does not implement $interface", 1003);
}
return $instance;

34
RemoteResponse/Chunk.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace dokuwiki\plugin\aichat\RemoteResponse;
use dokuwiki\plugin\aichat\Chunk as BaseChunk;
use dokuwiki\Remote\Response\ApiResponse;
class Chunk extends ApiResponse
{
/** @var string The page id of the source */
public $page;
/** @var string The title of the source page */
public $title;
/** @var string The chunk id of the source (pages are split into chunks) */
public $id;
/** @var float The similarity score of this source to the query (between 0 and 1) */
public $score;
/** @var string The language of the source */
public $lang;
public function __construct(BaseChunk $originalChunk)
{
$this->page = $originalChunk->getPage();
$this->id = $originalChunk->getId();
$this->score = $originalChunk->getScore();
$this->lang = $originalChunk->getLanguage();
$this->title = p_get_first_heading($this->page);
}
public function __toString()
{
return $this->page . '--' . $this->id;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace dokuwiki\plugin\aichat\RemoteResponse;
use dokuwiki\plugin\aichat\RemoteResponse\Chunk as ChunkResponse;
use dokuwiki\Remote\Response\ApiResponse;
class LlmReply extends ApiResponse
{
/** @var string The question as asked */
public $question;
/** @var string The answer provided by the LLM */
public $answer;
/** @var ChunkResponse[] The sources provided to the model to answer the questions */
public $sources = [];
public function __construct($data)
{
$this->question = $data['question'];
$this->answer = $data['answer'];
foreach ($data['sources'] as $source) {
$this->sources[] = new ChunkResponse($source);
}
}
public function __toString()
{
return $this->question;
}
}

View File

@ -138,6 +138,6 @@ abstract class AbstractStorage
*/
public function dumpTSV($vectorfile, $metafile)
{
throw new \RuntimeException('Not implemented for current storage');
throw new \RuntimeException('Not implemented for current storage', 4000);
}
}

View File

@ -63,13 +63,13 @@ class ChromaStorage extends AbstractStorage
$response = $this->http->resp_body;
if (!$response) {
throw new \Exception('Chroma API returned no response. ' . $this->http->error);
throw new \Exception('Chroma API returned no response. ' . $this->http->error, 4001);
}
try {
$result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR);
} catch (\Exception $e) {
throw new \Exception('Chroma API returned invalid JSON. ' . $response, 0, $e);
throw new \Exception('Chroma API returned invalid JSON. ' . $response, 4003, $e);
}
if ((int)$this->http->status !== 200) {
@ -85,7 +85,7 @@ class ChromaStorage extends AbstractStorage
$error = $this->http->error;
}
throw new \Exception('Chroma API returned error. ' . $error);
throw new \Exception('Chroma API returned error. ' . $error, 4002);
}
return $result;

View File

@ -50,17 +50,17 @@ class PineconeStorage extends AbstractStorage
$this->http->sendRequest($url, $json, $method);
$response = $this->http->resp_body;
if ($response === false) {
throw new \Exception('Pinecone API returned no response. ' . $this->http->error);
throw new \Exception('Pinecone API returned no response. ' . $this->http->error, 4001);
}
try {
$result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \Exception('Pinecone API returned invalid JSON. ' . $response, 0, $e);
throw new \Exception('Pinecone API returned invalid JSON. ' . $response, 4003, $e);
}
if (isset($result['message'])) {
throw new \Exception('Pinecone API returned error. ' . $result['message'], $result['code'] ?? 0);
throw new \Exception('Pinecone API returned error. ' . $result['message'], $result['code'] ?: 4002);
}
return $result;

View File

@ -80,12 +80,12 @@ class QdrantStorage extends AbstractStorage
return $this->runQuery($endpoint, $data, $method, $retry + 1);
}
throw new \Exception('Qdrant API returned invalid JSON. ' . $response, 0, $e);
throw new \Exception('Qdrant API returned invalid JSON. ' . $response, 4003, $e);
}
if ((int)$this->http->status !== 200) {
$error = $result['status']['error'] ?? $this->http->error;
throw new \Exception('Qdrant API returned error. ' . $error);
throw new \Exception('Qdrant API returned error. ' . $error, 4002);
}
return $result['result'] ?? $result;

View File

@ -310,7 +310,7 @@ class SQLiteStorage extends AbstractStorage
if ($this->logger) $this->logger->success('Created {clusters} clusters', ['clusters' => count($clusters)]);
} catch (\Exception $e) {
$this->db->getPdo()->rollBack();
throw new \RuntimeException('Clustering failed: ' . $e->getMessage(), 0, $e);
throw new \RuntimeException('Clustering failed: ' . $e->getMessage(), 4005, $e);
}
}

48
remote.php Normal file
View File

@ -0,0 +1,48 @@
<?php
use dokuwiki\Extension\RemotePlugin;
use dokuwiki\plugin\aichat\RemoteResponse\LlmReply;
use dokuwiki\Remote\AccessDeniedException;
/**
* DokuWiki Plugin aichat (Action Component)
*
* @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
* @author Andreas Gohr <gohr@cosmocode.de>
*/
class remote_plugin_aichat extends RemotePlugin
{
/**
*
* @param string $query The question to ask the LLM
* @param string $model The model to use, if empty the default model is used
* @param string $lang Language code to override preferUIlanguage setting. "auto" to force autodetection.
* @return LlmReply
*/
public function ask($query, $model = '', $lang = '')
{
/** @var helper_plugin_aichat $helper */
$helper = plugin_load('helper', 'aichat');
if ($model) {
$helper->updateConfig(
['chatmodel' => $model, 'rephasemodel' => $model]
);
}
if (!$helper->userMayAccess()) {
throw new AccessDeniedException('You are not allowed to use this plugin', 111);
}
if ($lang === 'auto') {
$helper->updateConfig(['preferUIlanguage' => 0]);
} elseif ($lang) {
$helper->updateConfig(['preferUIlanguage' => 1]);
global $conf;
$conf['lang'] = $lang;
}
$result = $helper->askQuestion($query);
return new LlmReply($result);
}
}