Files
EasySoft-ZenTaoPMS/module/ai/model.php
T

3143 lines
115 KiB
PHP

<?php
/**
* The model file of ai module of ZenTaoPMS.
*
* @copyright Copyright 2009-2023 禅道软件(青岛)有限公司(ZenTao Software (Qingdao) Co., Ltd. www.zentao.net)
* @license ZPL(https://zpl.pub/page/zplv12.html) or AGPL(https://www.gnu.org/licenses/agpl-3.0.en.html)
* @author Wenrui LI <liwenrui@easycorp.ltd>
* @package ai
* @link https://www.zentao.net
*/
class aiModel extends model
{
/**
* Action model.
*
* @var actionModel
* @access public
*/
public $action;
/**
* Model config.
*
* @var object
* @access public
*/
public $modelConfig;
/**
* Errors from last request.
*
* @var array
* @access public
*/
public $errors = array();
/**
* Check if object action is clickable, used in datatables.
*
* @param object $object object to check, model objects are supported, add support for more if needed.
* @param string $method method on object
* @access public
* @static
* @return boolean
*/
public function isClickable($object, $action)
{
$action = strtolower($action);
if(empty($object) || empty($action)) return false;
/* Assumes object is a language model record. */
if(in_array($this->app->rawMethod, array('models', 'modelview')))
{
if($action === 'modelenable') return $object->enabled != '1';
if($action === 'modeldisable') return $object->enabled != '0';
if($action === 'assistantpublish') return $object->enabled != '1';
if($action === 'assistantwithdraw') return $object->enabled != '0';
if($action === 'assistantedit') return $object->enabled != '1';
}
if(in_array($this->app->rawMethod, array('prompts', 'promptview')))
{
$executable = $this->isExecutable($object);
$published = $object->status == 'active';
if($action == 'promptassignrole') return common::hasPriv('ai', 'designPrompt') && !$published;
if($action == 'promptaudit') return common::hasPriv('ai', 'designPrompt') && $executable && !$published;
if($action == 'promptedit') return common::hasPriv('ai', 'promptedit');
if($action == 'promptpublish') return common::hasPriv('ai', 'promptpublish') && !$published && $executable;
if($action == 'promptunpublish') return common::hasPriv('ai', 'promptunpublish') && $published && $executable;
}
if(in_array($this->app->rawMethod, array('miniprograms', 'miniprogramview')))
{
$isPublished = $object->published == '1';
$isBuiltIn = $object->builtIn == '1';
if($action == 'editminiprogram') return common::hasPriv('ai', 'editMiniProgram') && !$isPublished && !$isBuiltIn;
if($action == 'testminiprogram') return common::hasPriv('ai', 'testMiniProgram') && !$isPublished && !$isBuiltIn;
if($action == 'publishminiprogram') return common::hasPriv('ai', 'publishMiniProgram') && !$isPublished;
if($action == 'unpublishminiprogram') return common::hasPriv('ai', 'unpublishMiniProgram') && $isPublished;
if($action == 'exportminiprogram') return common::hasPriv('ai', 'exportMiniProgram') && $isPublished && !$isBuiltIn;
}
return true;
}
/**
* Set model config with model stored in database.
*
* @param object $config
* @access public
* @return bool
*/
public function setModelConfig($config)
{
/* Extract model options if model is from DB. */
if(isset($config->credentials)) $config = $this->unserializeModel($config);
/* Abort if config is falsy. */
if(empty($config)) return false;
$this->modelConfig = $config;
return true;
}
/**
* Setup $modelConfig with model stored in database, or default model if not specified.
*
* @param int|mixed $modelID or falsy values, if falsy, will use default model.
* @access private
* @return bool
*/
private function useLanguageModel($modelID)
{
if(!empty($modelID)) $model = $this->getLanguageModel($modelID, true);
if(empty($model)) $model = $this->getDefaultLanguageModel();
if(empty($model)) return false;
return $this->setModelConfig($model);
}
/**
* Whether any LLM is configured and enabled.
*
* @access public
* @return boolean
*/
public function hasModelsAvailable()
{
$models = $this->getLanguageModels('', true);
return !empty($models);
}
/**
* Get list of configured LLMs.
*
* @param string $type type of LLM, empty for all, check keys of `$config->ai->models` for available types.
* @param bool $enabledOnly whether to only return enabled models
* @param pager $pager
* @param string $orderBy
* @access public
* @return array
*/
public function getLanguageModels($type = '', $enabledOnly = false, $pager = null, $orderBy = 'id_desc')
{
return $this->dao->select('*')->from(TABLE_AI_MODEL)
->where('deleted')->eq('0')
->beginIF(!empty($type))->andWhere('`type`')->eq($type)->fi()
->beginIF($enabledOnly)->andWhere('`enabled`')->eq('1')->fi()
->orderBy($orderBy)
->page($pager)
->fetchAll('id', false);
}
/**
* Get key-value of all LLMs, includes default.
*
* @return array
*/
public function getLanguageModelNamesWithDefault()
{
$models = $this->getLanguageModels();
$models = array_reduce($models, function ($carry, $model)
{
$carry[$model->id] = $model->name;
return $carry;
}, array('default' => $this->lang->ai->models->default));
return $models;
}
/**
* Get LLM config.
*
* @param int $modelID
* @param bool $enabledOnly
* @access public
* @return object|false LLM config, false if not found.
*/
public function getLanguageModel($modelID, $enabledOnly = false)
{
return $this->dao->select('*')->from(TABLE_AI_MODEL)
->where('id')->eq($modelID)
->beginIF($enabledOnly)->andWhere('`enabled`')->eq('1')->fi()
->andWhere('deleted')->eq('0')
->fetch();
}
/**
* Get default (first enabled available) LLM config.
*
* @access private
* @return object|false default LLM config, false if cannot find any.
*/
private function getDefaultLanguageModel()
{
$models = $this->getLanguageModels('', true, null, 'id_asc');
if(empty($models)) return false;
return current($models);
}
/**
* Format model config submitted from form for use with `createModel()` and `updateModel()`.
*
* @param object $model
* @access private
* @return object|false
*/
private function serializeModel($model)
{
/* Check if all credentials fields are present. */
if(empty($this->config->ai->vendorList[$model->vendor])) return false;
$credentials = new stdclass();
foreach($this->config->ai->vendorList[$model->vendor]['credentials'] as $credKey)
{
if(empty($model->$credKey)) return false;
$credentials->$credKey = $model->$credKey;
}
$modelConfig = new stdclass();
$modelConfig->name = $model->name;
$modelConfig->desc = $model->description;
$modelConfig->type = $model->type;
$modelConfig->vendor = $model->vendor;
$modelConfig->credentials = json_encode($credentials);
$modelConfig->enabled = $model->enabled;
if(!empty($model->proxyType) && !empty($model->proxyAddr))
{
$proxy = new stdclass();
$proxy->type = $model->proxyType;
$proxy->addr = $model->proxyAddr;
$modelConfig->proxy = json_encode($proxy);
}
else
{
$modelConfig->proxy = '';
}
return $modelConfig;
}
/**
* Extract model config for use with actual API calls or for viewing details.
*
* @param object $model
* @access public
* @return object|false
*/
public function unserializeModel($model)
{
$modelConfig = new stdclass();
$modelConfig->id = $model->id;
$modelConfig->name = $model->name;
$modelConfig->type = $model->type;
$modelConfig->vendor = $model->vendor;
$modelConfig->desc = $model->desc;
$modelConfig->enabled = $model->enabled;
$modelConfig->deleted = $model->deleted;
/* Set default name. */
if(empty($model->name)) $modelConfig->name = $this->lang->ai->models->typeList[$model->type];
/* Extract credential props. */
$credentials = json_decode($model->credentials);
foreach($credentials as $credKey => $credValue) $modelConfig->$credKey = $credValue;
/* Extract proxy options. */
if(!empty($model->proxy))
{
$proxy = json_decode($model->proxy);
foreach($proxy as $proxyKey => $proxyValue) $modelConfig->{'proxy' . ucfirst($proxyKey)} = $proxyValue;
}
return $modelConfig;
}
/**
* Create LLM configuration.
*
* @param object $model model config details.
* @access public
* @return int|false id of created LLM if success, otherwise false.
*/
public function createModel($model)
{
$model->enabled = '1';
$modelConfig = $this->serializeModel($model);
if(!$modelConfig) return false;
$modelConfig->createdDate = helper::now();
$modelConfig->createdBy = $this->app->user->account;
$this->dao->insert(TABLE_AI_MODEL)
->data($modelConfig)
->exec();
return dao::isError() ? false : $this->dao->lastInsertID();
}
/**
* Update LLM configuration.
*
* @param int $modelID
* @param object $model
* @access public
* @return bool
*/
public function updateModel($modelID, $model)
{
$currentModel = $this->getLanguageModel($modelID);
if(!$currentModel) return false;
$model->enabled = $currentModel->enabled;
$modelConfig = $this->serializeModel($model);
if(!$modelConfig) return false;
$modelConfig->editedDate = helper::now();
$modelConfig->editedBy = $this->app->user->account;
$this->dao->update(TABLE_AI_MODEL)
->data($modelConfig)
->where('id')->eq($modelID)
->exec();
if(isset($model->name))
{
$this->dao->update(TABLE_IM_CHAT)
->set('name')->eq($model->name)
->where('gid')->like( "%&ai-$modelID")
->exec();
}
return !dao::isError();
}
/**
* Toggle model enablement status.
*
* @param int $modelID
* @param bool $enabled
* @access public
* @return bool
*/
public function toggleModel($modelID, $enabled)
{
$this->dao->update(TABLE_AI_MODEL)
->set('enabled')->eq(empty($enabled) ? '0' : '1')
->set('editedDate')->eq(helper::now())
->set('editedBy')->eq($this->app->user->account)
->where('id')->eq($modelID)
->exec();
$this->dao->update(TABLE_IM_CHAT)
->set('archiveDate')->eq(empty($enabled) ? helper::now() : null)
->where('gid')->like( "%&ai-$modelID")
->exec();
return !dao::isError();
}
/**
* Mark model as deleted.
*
* @param int $modelID
* @access public
* @return bool
*/
public function deleteModel($modelID)
{
$this->dao->update(TABLE_AI_MODEL)
->set('deleted')->eq('1')
->set('editedDate')->eq(helper::now())
->set('editedBy')->eq($this->app->user->account)
->where('id')->eq($modelID)
->exec();
$this->dao->update(TABLE_IM_CHAT)
->set('dismissDate')->eq(helper::now())
->where('gid')->like( "%&ai-$modelID")
->exec();
$this->dao->update(TABLE_AI_ASSISTANT)
->set('deleted')->eq('1')
->where('modelId')->eq($modelID)
->exec();
return !dao::isError();
}
/**
* Make a test request to the model.
*
* @param int $modelID
* @access public
* @return bool
*/
public function testModelConnection($modelID)
{
$this->useLanguageModel($modelID);
if($this->config->ai->models[$this->modelConfig->type] == 'ernie' || $this->modelConfig->vendor == 'azure' || $this->modelConfig->type == 'openai-gpt4' || $this->modelConfig->vendor == 'openaiCompatible')
{
$messages = array((object)array('role' => 'user', 'content' => 'test'));
$result = $this->converse($modelID, $messages, array('maxTokens' => 1));
}
else
{
$result = $this->complete($modelID, 'test', 1); // Test completing 'test' with length of 1.
}
return !empty($result);
}
/**
* Make request to OpenAI API.
*
* @param string $type chat | completion | edit
* @param mixed $data data to send
* @param int $timeout request timeout in seconds
* @access private
* @return object response object, three properties: result, message (if fail), content (if success).
*/
private function makeRequest($type, $data, $timeout = 10)
{
$modelType = $this->config->ai->models[$this->modelConfig->type];
$modelVendor = $this->modelConfig->vendor;
/* Try encoding data to json, handles both encoded json and raw data. */
$postData = json_encode($data);
if(json_last_error()) $postData = $data;
/* Set auth and content-type headers. */
$requestHeaders = array();
if(isset($this->config->ai->$modelType->api->$modelVendor->authFormat)) $requestHeaders[] = sprintf($this->config->ai->$modelType->api->$modelVendor->authFormat, $this->modelConfig->key);
$requestHeaders[] = isset($this->config->ai->$modelType->contentType[$type]) ? $this->config->ai->$modelType->contentType[$type] : $this->config->ai->$modelType->contentType[''];
/* Assemble request url. */
if($modelType == 'openai')
{
if($modelVendor == 'openaiCompatible')
{
$url = sprintf($this->config->ai->openai->api->openaiCompatible->format, rtrim($this->modelConfig->base, '/'), $this->config->ai->openai->api->methods[$type]);
}
elseif($modelVendor == 'azure')
{
$url = sprintf($this->config->ai->openai->api->azure->format, $this->modelConfig->resource, $this->modelConfig->deployment, $this->config->ai->openai->api->methods[$type], $this->config->ai->openai->api->azure->apiVersion);
}
else
{
$url = sprintf($this->config->ai->openai->api->openai->format, $this->config->ai->openai->api->openai->version, $this->config->ai->openai->api->methods[$type]);
}
}
elseif($modelType == 'ernie')
{
$clientID = $this->modelConfig->key;
$clientSecret = $this->modelConfig->secret;
$authURL = sprintf($this->config->ai->ernie->api->$modelVendor->auth, $clientID, $clientSecret);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $authURL);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
$result = curl_exec($ch);
if(!$result || curl_errno($ch))
{
$response = new stdclass();
$response->result = 'fail';
$response->message = curl_error($ch);
curl_close($ch);
return $response;
}
$result = json_decode($result);
if(json_last_error())
{
$response = new stdclass();
$response->result = 'fail';
$response->message = 'JSON decode error: ' . json_last_error_msg();
curl_close($ch);
return $response;
}
if(empty($result->access_token)) return (object)array('result' => 'fail', 'message' => $this->lang->ai->models->authFailure);
$accessToken = $result->access_token;
$url = sprintf($this->config->ai->ernie->api->$modelVendor->format, $accessToken);
}
/* Set up requestor. */
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
curl_setopt($ch, CURLOPT_HTTPHEADER, $requestHeaders);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout);
/* Use proxy if proxy is set. */
if(!empty($this->modelConfig->proxyType) && !empty($this->modelConfig->proxyAddr))
{
curl_setopt($ch, CURLOPT_PROXY, $this->modelConfig->proxyAddr);
curl_setopt($ch, CURLOPT_PROXYTYPE, static::getProxyType($this->modelConfig->proxyType));
}
$result = curl_exec($ch);
if(isset($this->config->debug) && $this->config->debug >= 1)
{
global $app;
$logFile = $app->getLogRoot() . 'saas.' . date('Ymd') . '.log.php';
if(!file_exists($logFile)) file_put_contents($logFile, '<?php die(); ?' . '>');
$fh = @fopen($logFile, 'a');
if($fh)
{
fwrite($fh, date('Ymd H:i:s') . ": " . $app->getURI() . ' AI Request' . "\n");
fwrite($fh, "postData: " . print_r($data, true) . "\n");
fwrite($fh, "results:" . print_r($result, true) . "\n");
fclose($fh);
}
}
$response = new stdclass();
if(!$result || curl_errno($ch))
{
$response->result = 'fail';
$response->message = curl_error($ch);
}
else
{
$response->result = 'success';
$response->content = $result;
}
curl_close($ch);
return $response;
}
/**
* Get proxy type.
*
* @param string $proxyType
* @access private
* @return int|false
*/
private static function getProxyType($proxyType)
{
if(!in_array($proxyType, array('http', 'socks4', 'socks5'))) return false;
return constant('CURLPROXY_' . strtoupper($proxyType));
}
/**
* Convert camelCase to snake_case.
*
* @param string $str
* @access private
* @return string
*/
private static function camelCaseToSnakeCase($str)
{
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $str));
}
/**
* Standardize params keys, convert camelCase to snake_case.
*
* @param object $data
* @access private
* @return object
*/
private static function standardizeParams($data)
{
$standardizedData = new stdclass();
foreach($data as $key => $value) $standardizedData->{static::camelCaseToSnakeCase($key)} = $value;
return $standardizedData;
}
/**
* Assemble request data params, filters out the unwanted ones, and will return false if missing any required param.
*
* @param string $type
* @param object $data
* @access private
* @return mixed false if missing required params, post data object if success
*/
private function assembleRequestData($type, $data)
{
$modelType = $this->config->ai->models[$this->modelConfig->type];
/* Models of the same type may differ in version, in which case they are stored as an array. */
$modelName = $this->config->ai->$modelType->model->$type;
if(is_array($modelName)) $modelName = $modelName[$this->modelConfig->type];
$postData = new stdclass();
$postData->model = $modelName;
$data = static::standardizeParams($data);
/* Set required params, abort if missing. */
foreach($this->config->ai->$modelType->params->$type->required as $param)
{
if(!isset($data->$param)) return false;
$postData->$param = $data->$param;
}
/* Set optional params. */
foreach($this->config->ai->$modelType->params->$type->optional as $param)
{
if(isset($data->$param)) $postData->$param = $data->$param;
}
return $postData;
}
/**
* Decode response from OpenAI API, add error to $this->errors if any.
*
* @param object $response response object
* @access private
* @return mixed false if error, json object if success
*/
private function decodeResponse($response)
{
if($response->result === 'fail')
{
$this->errors[] = empty($response->message) ? 'Unknown error' : $response->message;
return false;
}
$response = json_decode($response->content);
if(json_last_error())
{
/* Polyfill for PHP 5 < 5.5.0. */
if(!function_exists('json_last_error_msg'))
{
function json_last_error_msg()
{
switch (json_last_error())
{
case JSON_ERROR_DEPTH:
return 'Maximum stack depth exceeded';
case JSON_ERROR_STATE_MISMATCH:
return 'Underflow or the modes mismatch';
case JSON_ERROR_CTRL_CHAR:
return 'Unexpected control character found';
case JSON_ERROR_SYNTAX:
return 'Syntax error, malformed JSON';
case JSON_ERROR_UTF8:
return 'Malformed UTF-8 characters, possibly incorrectly encoded';
default:
return 'Unknown error';
}
}
}
$this->errors[] = 'JSON decode error: ' . json_last_error_msg();
return false;
}
return $response;
}
/**
* Parse text responses from simple APIs. For example, completion.
*
* @param string $response json string
* @access private
* @return mixed false if error, array of texts (choices) if success
*/
private function parseTextResponse($response)
{
$response = $this->decodeResponse($response);
if(empty($response)) return false;
/* Extract text response choices. */
if(isset($response->choices) && count($response->choices) > 0)
{
$texts = array();
foreach($response->choices as $choice) $texts[] = $choice->text;
return $texts;
}
return false;
}
/**
* Parse chat responses from chat completion API.
*
* @param string $response json string
* @access private
* @return mixed false if error, array of chat message texts (choices) if success
*/
private function parseChatResponse($response)
{
$response = $this->decodeResponse($response);
if(empty($response)) return false;
if($this->config->ai->models[$this->modelConfig->type] == 'ernie')
{
/* Extract chat message text. */
if(!empty($response->result))
{
$messages = array($response->result);
return $messages;
}
}
else
{
/* Extract chat message choices. */
if(isset($response->choices) && count($response->choices) > 0)
{
$messages = array();
foreach($response->choices as $choice) $messages[] = $choice->message->content;
return $messages;
}
}
return false;
}
/**
* Parse function call argument of chat responses from chat completion API.
*
* @param string $response json string
* @access private
* @return mixed false if error, array of function call arguments (choices) if success
*/
private function parseFunctionCallResponse($response)
{
$response = $this->decodeResponse($response);
if(empty($response)) return false;
if($this->config->ai->models[$this->modelConfig->type] == 'ernie')
{
/* Extract function call arguments. */
if(!empty($response->function_call)) return array($response->function_call->arguments);
throw new AIResponseException('notFunctionCalling', $response);
}
else
{
/* Extract function call choices. */
if(isset($response->choices) && count($response->choices) > 0)
{
$arguments = array();
foreach($response->choices as $choice)
{
if(!empty($choice->message->function_call)) $arguments[] = $choice->message->function_call->arguments;
}
return $arguments;
}
}
return false;
}
/**
* Complete text with OpenAI GPT.
*
* @param int $model model id
* @param string $prompt text to complete
* @param int $maxTokens max tokens to generate
* @param array $options optional params, see https://platform.openai.com/docs/api-reference/completions/create
* @access public
* @return mixed false if error, array of texts (choices) if success
*/
public function complete($model, $prompt, $maxTokens = 512, $options = array())
{
if(empty($this->modelConfig) && !$this->useLanguageModel($model)) return false;
$data = compact('prompt', 'maxTokens');
if(!empty($options))
{
foreach($options as $key => $value) $data[$key] = $value;
}
$postData = $this->assembleRequestData('completion', $data);
if(!$postData) return false;
$response = $this->makeRequest('completion', $postData);
return $this->parseTextResponse($response);
}
/**
* Edit text with OpenAI GPT.
*
* @param int $model model id
* @param string $input text to edit
* @param string $instruction edit instruction
* @param array $options optional params, see https://platform.openai.com/docs/api-reference/edits/create
* @access public
* @return mixed false if error, array of texts (choices) if success
*/
public function edit($model, $input, $instruction, $options = array())
{
if(empty($this->modelConfig) && !$this->useLanguageModel($model)) return false;
$data = compact('input', 'instruction');
if(!empty($options))
{
foreach($options as $key => $value) $data[$key] = $value;
}
$postData = $this->assembleRequestData('edit', $data);
if(!$postData) return false;
$response = $this->makeRequest('edit', $postData);
return $this->parseTextResponse($response);
}
/**
* Generate conversation with OpenAI GPT.
*
* Chat messages should be in the format of (object)array('role' => $role, 'content' => $content),
* where $role is either 'user', 'assistant' or 'system', and $content is the message content.
*
* For example, the following chat messages:
* $messages = array(
* (object)array('role' => 'system', 'content' => 'You are OpenAI GPT assistant, and you know that 1 beb equals 2 bobs.'),
* (object)array('role' => 'user', 'content' => 'Hello, how many bobs are there in 24 bebs?')
* );
*
* API returns:
* $message = array('Hi, there are 48 bobs in 24 bebs.');
*
* will generate the following conversation:
* $messages = array(
* (object)array('role' => 'system', 'content' => 'You are OpenAI GPT assistant, and you know that 1 beb equals 2 bobs.'),
* (object)array('role' => 'user', 'content' => 'Hello, how many bobs are there in 24 bebs?'),
* (object)array('role' => 'assistant', 'content' => 'Hi, there are 48 bobs in 24 bebs.')
* );
*
* @param int $model model id
* @param array $messages array of chat messages
* @param array $options optional params, see https://platform.openai.com/docs/api-reference/chat/create
* @access public
* @return mixed false if error, array of chat messages if success
*/
public function converse($model, $messages, $options = array())
{
if(empty($this->modelConfig) && !$this->useLanguageModel($model)) return false;
/* Filter system message out for ERNIE. */
if($this->config->ai->models[$this->modelConfig->type] == 'ernie')
{
$systemMessage = current(array_filter($messages, function($message) { return $message->role == 'system'; }));
if(!empty($systemMessage)) $options['system'] = $systemMessage->content;
$messages = array_values(array_filter($messages, function($message) { return $message->role == 'assistant' || $message->role == 'user'; }));
}
$data = compact('messages');
if(!empty($options))
{
foreach($options as $key => $value) $data[$key] = $value;
}
$postData = $this->assembleRequestData('chat', $data);
if(!$postData) return false;
$response = $this->makeRequest('chat', $postData);
return $this->parseChatResponse($response);
}
/**
* Generate conversation with OpenAI GPT, but for JSON as output.
*
* Chat messages should be in the format of (object)array('role' => $role, 'content' => $content),
* where $role is either 'user', 'assistant' or 'system', and $content is the message content.
*
* Schema should be in the format of JSON schema, see https://json-schema.org/understanding-json-schema/.
*
* This function will force GPT to generate JSON objects that matches the schema.
*
* For technical details, see https://platform.openai.com/docs/guides/gpt/function-calling.
*
* @param int $model model id
* @param array $messages array of chat messages
* @param object $schema schema of the output
* @param array $options optional params, see https://platform.openai.com/docs/api-reference/chat/create
* @access public
* @return mixed false if error, array of JSON object if success
*/
public function converseForJSON($model, $messages, $schema, $options = array())
{
if(empty($this->modelConfig) && !$this->useLanguageModel($model)) return false;
$functions = array((object)array('name' => 'function', 'parameters' => $schema));
$functionCall = (object)array('name' => 'function');
$data = compact('messages', 'functions', 'functionCall');
if(!empty($options))
{
foreach($options as $key => $value) $data[$key] = $value;
}
$postData = $this->assembleRequestData('function', $data);
if(!$postData) return false;
$response = $this->makeRequest('function', $postData);
return $this->parseFunctionCallResponse($response);
}
/**
* Generate conversations with LLM for JSON output, but seperate the conversation into two parts.
*
* Usage is the same as function `converseForJSON`, but this function will generate two conversations:
* one for manipulating the natural language data, and the other for generating the JSON output.
*
* @param int $model model id
* @param array $messages array of chat messages
* @param object $schema schema of the output
* @param array $options optional params, see https://platform.openai.com/docs/api-reference/chat/create
* @access public
* @return mixed false if error, array of JSON object if success
* @throws AIResponseException
*/
public function converseTwiceForJSON($model, $messages, $schema, $options = array())
{
if(empty($this->modelConfig) && !$this->useLanguageModel($model)) return false;
/* First conversation. */
$data = compact('messages');
if(!empty($options))
{
foreach($options as $key => $value) $data[$key] = $value;
}
$postData = $this->assembleRequestData('chat', $data);
if(!$postData) return false;
$chatResponse = $this->makeRequest('chat', $postData);
$chatMessages = $this->parseChatResponse($chatResponse);
$chatMessage = current($chatMessages);
if(empty($chatMessage)) return false;
/* Second conversation for JSON output. */
$messages = array_merge($this->lang->ai->engineeredPrompts->askForFunctionCalling, array((object)array('role' => 'user', 'content' => $chatMessage)));
$functions = array((object)array('name' => 'function', 'description' => 'function', 'parameters' => $schema));
$data = compact('messages', 'functions');
if(!empty($options))
{
foreach($options as $key => $value) $data[$key] = $value;
}
$postData = $this->assembleRequestData('function', $data);
if(!$postData) return false;
$response = $this->makeRequest('function', $postData);
return $this->parseFunctionCallResponse($response);
}
/**
* Get latest mini programs.
*
* @param pager $pager
* @access public
* @return array
*/
public function getLatestMiniPrograms($pager = null, $order = 'publishedDate_desc')
{
return $this->dao->select('*')
->from(TABLE_AI_MINIPROGRAM)
->where('deleted')->eq('0')
->andWhere('published')->eq('1')
->andWhere('publishedDate')->ge(date('Y-m-d H:i:s', strtotime('-1 months')))
->orderBy($order)
->page($pager)
->fetchAll('id', false);
}
/**
* Count latest mini programs.
*
* @access public
* @return int
*/
public function countLatestMiniPrograms()
{
return (int)$this->dao->select('COUNT(1) AS count')
->from(TABLE_AI_MINIPROGRAM)
->where('deleted')->eq('0')
->andWhere('published')->eq('1')
->andWhere('createdDate')->ge(date('Y-m-d H:i:s', strtotime('-1 months')))
->fetch('count');
}
/**
* Save mini program message.
*
* @param string $appID
* @param string $type
* @param string $content
* @access public
* @return bool
*/
public function saveMiniProgramMessage($appID, $type, $content)
{
$message = new stdClass();
$message->appID = $appID;
$message->user = $this->app->user->id;
$message->type = $type;
$message->content = $content;
$message->createdDate = helper::now();
$this->dao->insert(TABLE_AI_MESSAGE)
->data($message)
->exec();
return !dao::isError();
}
/**
* Delete history messages by id.
*
* @param string $appID
* @param string $userID
* @param array $messageIDs
* @access public
* @return void
*/
public function deleteHistoryMessagesByID($appID, $userID, $messageIDs)
{
$this->dao->delete()
->from(TABLE_AI_MESSAGE)
->where('appID')->eq($appID)
->andWhere('user')->eq($userID)
->andWhere('id')->notin($messageIDs)
->exec();
}
/**
* Get history messages.
*
* @param string $appID
* @param int $limit
* @access public
* @return array
*/
public function getHistoryMessages($appID, $limit = 20)
{
$messages = $this->dao->select('*')
->from(TABLE_AI_MESSAGE)
->where('appID')->eq($appID)
->andWhere('user')->eq($this->app->user->id)
->orderBy('createdDate_desc')
->limit($limit)
->fetchAll('id', false);
$messageIDs = array();
foreach($messages as $message)
{
$message->createdDate = date('Y/n/j G:i', strtotime($message->createdDate));
$messageIDs[] = $message->id;
}
$this->deleteHistoryMessagesByID($appID, $this->app->user->id, $messageIDs);
return $messages;
}
/**
* Get mini programs by appid.
*
* @param array $ids
* @param bool $sort
* @access public
* @return array
*/
public function getMiniProgramsByID($ids, $sort = false)
{
$miniPrograms = $this->dao->select('*')
->from(TABLE_AI_MINIPROGRAM)
->where('id')->in($ids)
->fetchAll('id', false);
if(!$sort) return $miniPrograms;
$sortIDs = array_flip($ids);
$sortedPrograms = array();
foreach($miniPrograms as $program) $sortedPrograms[$sortIDs[$program->id]] = $program;
ksort($sortedPrograms);
return $sortedPrograms;
}
/**
* Get mini program by appid.
*
* @param string $id
* @access public
* @return object
*/
public function getMiniProgramByID($id)
{
return $this->dao->select('*')
->from(TABLE_AI_MINIPROGRAM)
->where('id')->eq($id)
->fetch();
}
/**
* Get custom categories.
*
* @access public
* @return array
*/
public function getCustomCategories()
{
return $this->dao->select('`key`,`value`')
->from(TABLE_CONFIG)
->where('module')->eq('ai')
->andWhere('section')->eq('miniProgram')
->fetchPairs();
}
/**
* Get used custom categories.
*
* @access public
* @return array
*/
public function getUsedCustomCategories()
{
$categories = $this->dao->select('distinct `category`')
->from(TABLE_AI_MINIPROGRAM)
->where('deleted')->eq('0')
->fetchAll('category');
return array_keys($categories);
}
/**
* Get published custom categories.
*
* @access public
* @return array
*/
public function getPublishedCustomCategories()
{
$categories = $this->dao->select('distinct `category`')
->from(TABLE_AI_MINIPROGRAM)
->where('published')->eq('1')
->fetchAll('category');
return array_keys($categories);
}
/**
* Check duplicated category name.
*
* @access public
* @return bool
*/
public function checkDuplicatedCategory()
{
$data = array_filter($_POST);
if(empty($data)) return false;
$categories = array_values($this->lang->ai->miniPrograms->categoryList);
foreach($data as $value)
{
if(is_string($value))
{
if(in_array($value, $categories)) return true;
$categories[] = $value;
}
if(is_array($value))
{
$value = array_filter($value);
foreach($value as $v)
{
if(in_array($v, $categories)) return true;
$categories[] = $v;
}
}
}
return false;
}
/**
* Update custom categories.
*
* @access public
* @access public
* @return void
*/
public function updateCustomCategories()
{
$this->dao->delete()
->from(TABLE_CONFIG)
->where('module')->eq('ai')
->andWhere('section')->eq('miniProgram')
->exec();
$data = array_filter($_POST);
if(empty($data)) return;
foreach($data as $key => $value)
{
if(is_string($value))
{
$category = new stdClass();
$category->module = 'ai';
$category->section = 'miniProgram';
$category->owner = 'system';
$category->vision = '';
$category->key = $key;
$category->value = $value;
$this->dao->insert(TABLE_CONFIG)
->data($category)
->exec();
continue;
}
if(is_array($value))
{
$value = array_filter($value);
foreach($value as $v)
{
$category = new stdClass();
$category->module = 'ai';
$category->section = 'miniProgram';
$category->owner = 'system';
$category->vision = '';
$category->key = uniqid('custom-');
$category->value = $v;
$this->dao->insert(TABLE_CONFIG)
->data($category)
->exec();
}
}
}
}
/**
* Get not deleted mini programs.
*
* @param string $category
* @param string $status
* @param string $order
* @param pager $pager
* @access public
* @return array
*/
public function getMiniPrograms($category = '', $status = '', $order = 'createdDate_desc', $pager = null)
{
$programs = $this->dao->select('*')
->from(TABLE_AI_MINIPROGRAM)
->where('deleted')->eq('0')
->beginIF(!empty($category))->andWhere('category')->eq($category)->fi()
->beginIF($status === 'active')->andWhere('published')->eq('1')->fi()
->beginIF($status === 'draft')->andWhere('published')->ne('1')->fi()
->beginIF($status === 'createdByMe')->andWhere('createdBy')->eq($this->app->user->account)->fi()
->orderBy($order)
->page($pager)
->fetchAll('id', false);
$this->loadModel('common')->saveQueryCondition($this->dao->get(), 'miniprogram');
return $programs;
}
/**
* Get mini program fields by appid.
*
* @param string $appID
* @access public
* @return array
*/
public function getMiniProgramFields($appID)
{
return $this->dao->select('*')
->from(TABLE_AI_MINIPROGRAMFIELD)
->where('appID')->eq($appID)
->fetchAll('id', false);
}
/**
* @param string $appID
* @access public
* @return void
*/
public function createNewVersionNotification($appID)
{
$users = $this->dao->select('user')
->from(TABLE_AI_MESSAGE)
->where('appID')->eq($appID)
->fetchAll('user');
$users = array_keys($users);
foreach($users as $user)
{
$data = new stdClass();
$data->appID = $appID;
$data->user = $user;
$data->type = 'ntf';
$data->content = sprintf($this->lang->ai->miniPrograms->newVersionTip, date("Y/n/j G:i"));
$data->createdDate = helper::now();
$this->dao->delete()
->from(TABLE_AI_MESSAGE)
->where('appID')->eq($appID)
->andWhere('user')->eq($user)
->andWhere('type')->eq('ntf')
->exec();
$this->dao->insert(TABLE_AI_MESSAGE)
->data($data)
->exec();
}
}
/**
* Change mini program `publish` value.
*
* @param string $appID
* @param string $published
* @access public
* @return bool
*/
public function publishMiniProgram($appID, $published = '1')
{
$data = new stdClass();
$data->published = $published;
if($published == '1')
{
$data->publishedDate = helper::now();
$miniProgram = $this->getMiniProgramByID($appID);
if(!empty($miniProgram->publishedDate))
{
$this->createNewVersionNotification($appID);
}
}
$this->dao->update(TABLE_AI_MINIPROGRAM)
->data($data)
->where('id')->eq($appID)
->exec();
$this->loadModel('action')->create('miniProgram', $appID, $published == '1' ? 'published' : 'unpublished');
if($published !== '1') $this->collectMiniProgram(null, $appID, 'true');
return !dao::isError();
}
/**
* Collect mini program.
*
* @param string $userID
* @param string $appID
* @param string $delete
* @access public
* @return bool
*/
public function collectMiniProgram($userID, $appID, $delete = 'false')
{
if($delete === 'true')
{
$this->dao->delete()
->from(TABLE_AI_MINIPROGRAMSTAR)
->where('appID')->eq($appID)
->beginIF(!empty($userID))->andWhere('userID')->eq($userID)->fi()
->exec();
return !dao::isError();
}
$data = new stdClass();
$data->appID = $appID;
$data->userID = $userID;
$data->createdDate = helper::now();
$this->dao->insert(TABLE_AI_MINIPROGRAMSTAR)
->data($data)
->exec();
return !dao::isError();
}
/**
* Determine whether Mini programs can be published.
*
* @param object $program
* @access public
* @return bool
*/
public function canPublishMiniProgram($program)
{
$isNotEmpty = function ($str)
{
return isset($str) && strlen(strval($str)) > 0;
};
return $isNotEmpty($program->id)
&& $isNotEmpty($program->name)
&& $isNotEmpty($program->desc)
&& $isNotEmpty($program->category)
&& $isNotEmpty($program->model)
&& $isNotEmpty($program->prompt);
}
/**
* Create a mini program.
*
* @access public
* @return int|false
*/
public function createMiniProgram($data = null)
{
if(empty($data)) $data = fixer::input('post')->get();
if(!isset($data->editedBy)) $data->editedBy = $this->app->user->account;
if(!isset($data->editedDate)) $data->editedDate = helper::now();
if(!isset($data->published)) $data->published = '0';
if(!isset($data->createdBy)) $data->createdBy = $this->app->user->account;
if(!isset($data->createdDate)) $data->createdDate = helper::now();
if(!isset($data->prompt)) $data->prompt = $this->lang->ai->miniPrograms->field->default[3];
if($data->published == '1') $data->publishedDate = helper::now();
$data->builtIn = '0';
if(!empty($data->iconName) && !empty($data->iconTheme))
{
$data->icon = $data->iconName . '-' . $data->iconTheme;
unset($data->iconName);
unset($data->iconTheme);
}
if(isset($data->fields))
{
$fields = $data->fields;
unset($data->fields);
}
$this->dao->insert(TABLE_AI_MINIPROGRAM)
->data($data)
->exec();
$appID = $this->dao->lastInsertID();
if(!isset($fields))
{
$defaultFields = $this->lang->ai->miniPrograms->field->default;
$fieldData = new stdClass();
$fieldData->fields = array();
for($i = 0; $i < 3; $i++)
{
$field = new stdClass();
$field->appID = $appID;
$field->name = $defaultFields[$i];
$field->type = 'text';
$field->placeholder = $this->lang->ai->miniPrograms->placeholder->input;
$field->options = null;
$field->required = '0';
$fieldData->fields[] = $field;
}
$this->saveMiniProgramFields($appID, $fieldData);
}
else
{
$fieldData = new stdClass();
$fieldData->fields = $fields;
foreach($fields as $field)
{
if(!isset($field->appID)) $field->appID = $appID;
}
$this->saveMiniProgramFields($appID, $fieldData);
}
if(dao::isError()) return false;
$this->loadModel('action')->create('miniProgram', $appID, 'created');
return $appID;
}
/**
* Extract ZT app zip file, only extract ztapp.json file.
*
* @param string $file
* @access public
* @return bool
*/
public function extractZtAppZip($file)
{
$this->app->loadClass('pclzip', true);
$zip = new pclzip($file);
$filePath = $this->app->getAppRoot() . 'tmp/';
/* 限制解压的文件内容以阻止 ZIP 解压缩的目录穿越漏洞。*/
/* Limit the file content to prevent the directory traversal vulnerability of ZIP decompression. */
$extractFiles = array('ztapp.json');
return $zip->extract(PCLZIP_OPT_PATH, $filePath, PCLZIP_OPT_BY_NAME, $extractFiles, PCLZIP_OPT_REMOVE_PATH, '');
}
/**
* Save mini program fields.
*
* @param string $appID
* @access public
* @return void
*/
public function saveMiniProgramFields($appID, $data = null)
{
if(empty($data)) $data = fixer::input('post')->get();
if(isset($data->prompt))
{
$this->dao->update(TABLE_AI_MINIPROGRAM)
->set('prompt')->eq($data->prompt)
->where('id')->eq($appID)
->exec();
}
if(isset($data->knowledgeLib))
{
$this->dao->update(TABLE_AI_MINIPROGRAM)
->set('knowledgeLib')->eq($data->knowledgeLib)
->where('id')->eq($appID)
->exec();
}
$this->dao->delete()
->from(TABLE_AI_MINIPROGRAMFIELD)
->where('appID')->eq($appID)
->exec();
$fields = $data->fields;
foreach($fields as $field)
{
$field = (array)$field;
if(isset($field['options']) && is_array($field['options'])) $field['options'] = implode(',', $field['options']);
$this->dao->insert(TABLE_AI_MINIPROGRAMFIELD)
->data($field)
->exec();
}
}
/**
* Check for duplicate names of Mini programs.
*
* @param string $name
* @param string|null $appID
* @access public
* @return boolean
*/
public function checkDuplicatedAppName($name, $appID = '-1')
{
$miniProgram = $this->dao->select('*')
->from(TABLE_AI_MINIPROGRAM)
->where('name')->eq($name)
->andWhere('id')->ne($appID)
->andWhere('deleted')->eq('0')
->fetch();
return !empty($miniProgram);
}
/**
* Get unique app name.
*
* @param string $name
* @return string
*/
public function getUniqueAppName($name)
{
while($this->checkDuplicatedAppName($name))
{
$name = $name . '_1';
}
return $name;
}
/**
* Verify that any required fields are empty.
*
* @param array $requiredFields
* @access public
* @return false|array false or errors.
*/
public function verifyRequiredFields($requiredFields)
{
$errors = array();
foreach($requiredFields as $field => $fieldLang)
{
if(!isset($_POST[$field]) || $_POST[$field] === "") $errors[$field] = sprintf($this->lang->error->notempty, $fieldLang);
}
if(!empty($errors)) return $errors;
return false;
}
/**
* Get list of prompts.
*
* @param string $module
* @param string $status
* @access public
* @return array
*/
public function getPrompts($module = '', $status = '', $order = 'id_desc', $pager = null)
{
$prompts = $this->dao->select('*')->from(TABLE_AI_AGENT)
->where('deleted')->eq(0)
->beginIF(!empty($module))->andWhere('module')->eq($module)->fi()
->beginIF(!empty($status))->andWhere('status')->eq($status)->fi()
->orderBy($order)
->page($pager)
->fetchAll('id', false);
$this->loadModel('common')->saveQueryCondition($this->dao->get(), 'prompt');
return $prompts;
}
/**
* Get prompt by id.
*
* @param int $id
* @access public
* @return object
*/
public function getPromptById($id)
{
return $this->dao->select('*')->from(TABLE_AI_AGENT)
->where('id')->eq($id)
->fetch();
}
/**
* Get prompt fields by prompt id.
*
* @param int $promptID
* @access public
* @return array
*/
public function getPromptFields(int $promptID): array
{
return $this->dao->select('*')
->from(TABLE_AI_AGENTFIELD)
->where('appID')->eq($promptID)
->fetchAll('id', false);
}
/**
* Create a prompt.
*
* @param object $prompt
* @access public
* @return int|false returns prompt id on success, false on fail
*/
public function createPrompt($prompt)
{
$prompt->createdDate = helper::now();
$prompt->createdBy = $this->app->user->account;
/* Override uniqueness error message. */
$this->lang->error->unique = $this->lang->ai->validate->nameNotUnique;
$this->dao->insert(TABLE_AI_AGENT)
->data($prompt)
->batchCheck($this->config->ai->createprompt->requiredFields, 'notempty')
->check('name', 'unique')
->autoCheck()
->exec();
if(dao::isError()) return false;
$promptId = $this->dao->lastInsertID();
$this->loadModel('action')->create('prompt', $promptId, 'created');
return $promptId;
}
/**
* Save prompt fields.
*
* @param int $promptID
* @param array $fields
* @access public
* @return void
*/
public function savePromptFields(int $promptID, array $fields): void
{
$this->dao->delete()
->from(TABLE_AI_AGENTFIELD)
->where('appID')->eq($promptID)
->exec();
foreach($fields as $field)
{
if(!is_object($field)) $field = (object)$field;
if(!isset($field->appID)) $field->appID = $promptID;
$this->dao->insert(TABLE_AI_AGENTFIELD)
->data($field)
->exec();
}
}
/**
* Update a prompt.
*
* @param object $prompt
* @param object $originalPrompt optional, original prompt to compare with and generate action.
* @access public
* @return bool
*/
public function updatePrompt($prompt, $originalPrompt = null)
{
/* Action name to create action record with. */
$actionType = 'edited';
/* Compare with original, check what changed. */
if(!empty($originalPrompt))
{
$changedFields = array();
foreach($prompt as $key => $value)
{
if($value != $originalPrompt->$key) $changedFields[] = $key;
}
/* If only status changed, action is either published or unpublished. */
if(count($changedFields) == 1 && current($changedFields) == 'status')
{
$actionType = $prompt->status == 'draft' ? 'unpublished' : 'published';
}
else
{
$changes = commonModel::createChanges($originalPrompt, $prompt);
}
}
$prompt->editedDate = helper::now();
$prompt->editedBy = $this->app->user->account;
/* Override uniqueness error message. */
$this->lang->error->unique = $this->lang->ai->validate->nameNotUnique;
$this->dao->update(TABLE_AI_AGENT)
->data($prompt)
->batchCheck($this->config->ai->createprompt->requiredFields, 'notempty')
->check('name', 'unique', "`id` != {$prompt->id}")
->autoCheck()
->where('id')->eq($prompt->id)
->exec();
if(dao::isError()) return false;
$actionId = $this->loadModel('action')->create('prompt', $prompt->id, $actionType);
if(!empty($changes)) $this->action->logHistory($actionId, $changes);
return true;
}
/**
* Delete a prompt.
*
* @param int $id
* @access public
* @return bool
*/
public function deletePrompt($id)
{
$prompt = $this->getPromptById($id);
if(empty($prompt)) return false;
$this->dao->update(TABLE_AI_AGENT)
->set('deleted')->eq(1)
->where('id')->eq($id)
->exec();
if(dao::isError()) return false;
$this->loadModel('action')->create('prompt', $id, 'deleted');
return true;
}
/**
* Toggle prompt status.
*
* @param int|object $prompt prompt (or id) to toggle.
* @param string $status optional, will set status to $status if provided.
* @access public
* @return bool
*/
public function togglePromptStatus($prompt, $status = '')
{
if(is_numeric($prompt)) $prompt = $this->getPromptById($prompt);
if(empty($prompt)) return false;
$flipedStatus = $prompt->status == 'draft' ? 'active' : 'draft';
$originalPrompt = clone $prompt;
$prompt->status = empty($status) ? $flipedStatus : $status;
return $this->updatePrompt($prompt, $originalPrompt);
}
/**
* Serialize data to prompt.
*
* @param string $module
* @param array|string $sources both raw `$prompt->sources` and `array(array('objectName', 'objectKey'), ...)` are supported.
* @param array|object $data array of data to be serialized
* @access public
* @return string
*/
public function serializeDataToPrompt($module, $sources, $data)
{
if(empty($data)) return '';
/* Handle object data. */
if(is_object($data)) $data = (array)$data;
/* Handle raw (non-exploded) sources. */
if(is_string($sources))
{
if(empty($sources)) return '';
if(strpos($sources, ',') !== false) $sources = array_filter(explode(',', $sources));
else $sources = array($sources);
$sources = array_map(function ($source)
{
return explode('.', $source);
}, $sources);
}
/* Handle empty sources array. */
if(empty($sources)) return '';
$storyData = [];
if(in_array(['task', 'story'], $sources, true) && $data['task']['story'])
{
$story = $this->loadModel('story')->getById($data['task']['story']);
$fields = ['title', 'spec', 'verify', 'product', 'module', 'pri', 'type', 'estimate'];
$storyData = [];
foreach($fields as $field)
{
$langLabel = $this->lang->story->$field;
$value = $story->$field;
if($field == 'title') $langLabel = $this->lang->story->name;
if($field == 'type') $value = zget($this->lang->story->typeList, $story->type);
if($field == 'spec' || $field == 'verify') $value = strip_tags($value);
$storyData[$langLabel] = $value;
}
}
$dataObject = array();
$supplement = '';
$supplementTypes = array();
foreach($sources as $source)
{
$objectName = $source[0];
$objectKey = $source[1];
if(!isset($this->lang->ai->dataSource[$module][$objectName]['common'])) continue;
if(!isset($this->lang->ai->dataSource[$module][$objectName][$objectKey])) continue;
$semanticName = $this->lang->ai->dataSource[$module][$objectName]['common'];
$semanticKey = $this->lang->ai->dataSource[$module][$objectName][$objectKey];
if(empty($dataObject[$semanticName])) $dataObject[$semanticName] = array();
$obj = $data[$objectName];
if(static::isAssoc($obj))
{
$dataObject[$semanticName][$semanticKey] = isset($data[$objectName][$objectKey]) ? $data[$objectName][$objectKey] : '';
}
else
{
foreach(array_keys($obj) as $idx)
{
if(empty($dataObject[$semanticName][$idx])) $dataObject[$semanticName][$idx] = array();
if(isset($data[$objectName][$idx][$objectKey])) $dataObject[$semanticName][$idx][$semanticKey] = $data[$objectName][$idx][$objectKey];
}
}
if(!empty($storyData)) $dataObject[$semanticName] = array_merge($dataObject[$semanticName], $storyData);
if(in_array($objectKey, $supplementTypes) || !isset($this->lang->ai->dataType->$objectKey)) continue;
$supplementTypes[] = $objectKey;
$supplement .= sprintf($this->lang->ai->dataTypeDesc, $semanticKey, $this->lang->ai->dataType->$objectKey->type, $this->lang->ai->dataType->$objectKey->desc) . "\n";
}
/* @see https://stackoverflow.com/a/2934602 */
return preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', function ($match)
{
return mb_convert_encoding(pack('H*', $match[1]), 'UTF-8', 'UCS-2BE');
}, json_encode($dataObject)) . (empty($supplement) ? '' : ("\n" . $supplement));
}
/**
* Generate demo data prompt by source.
*
* @param string $module
* @param string $source
* @access public
* @return string
*/
public function generateDemoDataPrompt($module, $source)
{
if(empty($this->lang->ai->demoData->$module)) return $this->lang->ai->demoData->notExist;
$sources = explode(',', $source);
$sources = array_filter($sources);
if(empty($sources)) return '';
foreach($sources as $index => $source)
{
$sources[$index] = explode('.', $source);
}
$data = array();
foreach($sources as $source)
{
$objectName = $source[0];
$objectKey = $source[1];
if(empty($data[$objectName])) $data[$objectName] = array();
$demoData = $this->lang->ai->demoData->$module[$objectName];
if(static::isAssoc($demoData))
{
$data[$objectName][$objectKey] = $demoData[$objectKey];
}
else
{
foreach($demoData as $index => $value)
{
if(empty($data[$objectName][$index])) $data[$objectName][$index] = array();
$data[$objectName][$index][$objectKey] = $value[$objectKey];
}
}
}
return $this->serializeDataToPrompt($module, $sources, $data);
}
/**
* Determines if an array is associative.
*
* @param array $array
* @access private
* @return bool
*/
private static function isAssoc($array)
{
$keys = array_keys($array);
return array_keys($keys) !== $keys;
}
/**
* Get function call schema for form.
*
* @param string $form
* @access public
* @return array
*/
public function getFunctionCallSchema($form)
{
if($form == 'empty.empty') return $form;
$formPath = explode('.', $form);
if(count($formPath) !== 2) return array();
$targetForm = $this->config->ai->targetForm[$formPath[0]][$formPath[1]];
if(empty($targetForm)) return array();
$schema = $this->lang->ai->formSchema[strtolower($targetForm->m)][strtolower($targetForm->f)];
return empty($schema) ? array() : $schema;
}
/**
* Get object data for prompt by id.
*
* @param object $prompt prompt object
* @param int $objectId object id
* @access public
* @return array|false array of object data and object, or false if error.
*/
public function getObjectForPromptById(object $prompt, int $objectId)
{
$module = $prompt->module;
$sources = array_filter(explode(',', $prompt->source));
/* Explode into grouped sources list. */
$sourceGroups = array();
foreach($sources as $source)
{
$source = explode('.', $source);
$objectName = $source[0];
$objectKey = $source[1];
if(empty($sourceGroups[$objectName])) $sourceGroups[$objectName] = array();
$sourceGroups[$objectName][] = $objectKey;
}
$object = new stdclass();
$objectData = new stdclass();
/* Query necessary object data from zentao. */
switch ($module)
{
case 'story':
if(isset($sourceGroups['story'])) $object->story = $this->loadModel('story')->getById($objectId);
break;
case 'execution':
if(isset($sourceGroups['execution'])) $object->execution = $this->loadModel('execution')->getByID($objectId);
if(isset($sourceGroups['tasks'])) $object->tasks = array_values($this->loadModel('task')->getExecutionTasks($objectId));
break;
case 'product':
if(isset($sourceGroups['product'])) $object->product = $this->loadModel('product')->getById($objectId);
break;
case 'project':
if(isset($sourceGroups['project'])) $object->project = $this->loadModel('project')->getById($objectId);
if($object->project->model == 'waterfall' && isset($sourceGroups['programplans'])) $object->programplans = array_values($this->loadModel('execution')->getByProject($object->project->id));
if($object->project->model != 'waterfall' && isset($sourceGroups['executions'])) $object->executions = array_values($this->loadModel('execution')->getByProject($object->project->id));
break;
case 'release':
if(isset($sourceGroups['release'])) $object->release = $this->loadModel('release')->getById($objectId);
if(isset($sourceGroups['stories'])) $object->stories = array_values($this->loadModel('story')->getByList(array_filter(explode(',', $object->release->stories))));
if(isset($sourceGroups['bugs'])) $object->bugs = array_values($this->loadModel('bug')->getByIdList(array_filter(explode(',', $object->release->bugs))));
break;
case 'productplan':
if(isset($sourceGroups['productplan'])) $object->productplan = $this->loadModel('productplan')->getByID($objectId);
if(isset($sourceGroups['stories'])) $object->stories = array_values($this->loadModel('story')->getPlanStories($objectId));
if(isset($sourceGroups['bugs'])) $object->bugs = array_values($this->dao->select('*')->from(TABLE_BUG)->where('plan')->eq($objectId)->andWhere('deleted')->eq(0)->fetchAll('id', false));
break;
case 'task':
if(isset($sourceGroups['task'])) $object->task = $this->loadModel('task')->getById($objectId);
break;
case 'case':
if(isset($sourceGroups['case'])) $object->case = $this->loadModel('testcase')->getById($objectId);
if(isset($sourceGroups['steps'])) $object->steps = array_values($object->case->steps);
break;
case 'bug':
if(isset($sourceGroups['bug'])) $object->bug = $this->loadModel('bug')->getById($objectId);
break;
case 'doc':
if(isset($sourceGroups['doc'])) $object->doc = $this->loadModel('doc')->getById($objectId);
break;
case 'my':
/* TODO: add more later. */
break;
}
$objectVars = get_object_vars($object);
if(empty($objectVars)) return false;
/* Format data as per data source definitions. */
foreach($sourceGroups as $objectName => $objectKeys)
{
$objectData->$objectName = array();
foreach($objectKeys as $objectKey)
{
if(!isset($object->$objectName)) continue;
/* Check if is plain object, data might contain arrays. */
if(is_object($object->$objectName))
{
if(isset($object->$objectName->$objectKey)) $objectData->$objectName[$objectKey] = $object->$objectName->$objectKey;
}
elseif(is_array($object->$objectName))
{
foreach($object->$objectName as $idx => $obj)
{
if(isset($obj->$objectKey)) $objectData->$objectName[$idx][$objectKey] = $obj->$objectKey;
}
}
}
}
return array($objectData, $object);
}
/**
* Auto prepend newline to text if text has newline in the middle.
*
* @param string $text
* @access private
* @return string
*/
private static function autoPrependNewline($text)
{
if(empty($text)) return '';
preg_match('/\n[^$]/', $text, $matches);
return empty($matches) ? $text : "\n$text";
}
/**
* Try to punctuate sentence if sentence is not ended with punctuation.
*
* @param string $sentence
* @param bool $newline
* @access private
* @return string
*/
private static function tryPunctuate($sentence, $newline = false)
{
if(empty($sentence)) return '';
preg_match('/\p{P}$/u', $sentence, $matches);
if(empty($matches)) $sentence .= '.';
return $newline ? "$sentence\n" : $sentence;
}
/**
* Assemble prompt with prompt data.
*
* @param object $prompt
* @param string $dataPrompt
* @access public
* @return string
*/
public static function assemblePrompt($prompt, $dataPrompt)
{
$wholePrompt = empty($dataPrompt) ? '' : "$dataPrompt\n";
$wholePrompt .= static::tryPunctuate($prompt->role);
$wholePrompt .= static::autoPrependNewline(static::tryPunctuate($prompt->characterization, true));
$wholePrompt .= static::autoPrependNewline(static::tryPunctuate($prompt->purpose));
$wholePrompt .= static::autoPrependNewline(static::tryPunctuate($prompt->elaboration, true));
return $wholePrompt;
}
/**
* Execute prompt on object.
*
* @param int|object $prompt prompt (or id) to execute.
* @param int|object $object object (or id) to execute prompt on.
* @access public
* @return string|int returns either JSON string or negative integer on error.
* @throws AIResponseException
*/
public function executePrompt($prompt, $object)
{
if(is_numeric($prompt)) $prompt = $this->getPromptById($prompt);
if(empty($prompt)) return -1;
if(is_numeric($object)) $object = $this->getObjectForPromptById($prompt, $object);
if(empty($object)) return -2;
list($objectData) = $object;
$dataPrompt = $this->serializeDataToPrompt($prompt->module, $prompt->source, $objectData);
if(empty($dataPrompt)) return -3;
$role = static::tryPunctuate($prompt->role);
$role .= static::autoPrependNewline(static::tryPunctuate($prompt->characterization, true));
$schema = $this->getFunctionCallSchema($prompt->targetForm);
if(empty($schema)) return -5;
$this->useLanguageModel($prompt->model);
return array('role' => $role, 'schema' => $schema, 'dataPrompt' => $dataPrompt, 'name' => $prompt->name, 'purpose' => $prompt->purpose, 'status' => $prompt->status, 'targetForm' => $prompt->targetForm, 'promptID' => $prompt->id);
}
/**
* Check if prompt can be tested.
*
* @param object|int $prompt prompt object or prompt id
* @access public
* @return bool
*/
public function isExecutable($prompt)
{
if(is_numeric($prompt)) $prompt = $this->getByID($prompt);
if(empty($prompt)) return false;
$executable = true;
$requiredFields = explode(',', $this->config->ai->testPrompt->requiredFields);
foreach($requiredFields as $field)
{
if(empty($prompt->$field) || $prompt->$field == ',,')
{
$executable = false;
break;
}
}
return $executable;
}
/**
* Get testing location for prompt, usually a link to object with max id and is accessable by user.
*
* @param object $prompt
* @access public
* @return string|false
*/
public function getTestingLocation($prompt)
{
$module = $prompt->module;
if($module == 'my')
{
return helper::createLink('my', 'effort', "type=all");
}
elseif($module == 'product')
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_PRODUCT)
->where('id')->in($this->app->user->view->products)
->fetch('maxId');
}
elseif($module == 'productplan')
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_PRODUCTPLAN)
->where('product')->in($this->app->user->view->products)
->fetch('maxId');
}
elseif($module == 'release')
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_RELEASE)
->where('project')->in($this->app->user->view->projects)
->orWhere('product')->in($this->app->user->view->products)
->fetch('maxId');
}
elseif($module == 'project')
{
/* programplan/create only exist in the waterfall model project. */
if(strpos($prompt->targetForm, 'programplan/create'))
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_PROJECT)
->where('id')->in($this->app->user->view->projects)
->andWhere('model')->eq('waterfall')
->fetch('maxId');
}
else
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_PROJECT)
->where('id')->in($this->app->user->view->projects)
->fetch('maxId');
}
}
elseif($module == 'story')
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_STORY)
->where('product')->in($this->app->user->view->products)
->fetch('maxId');
}
elseif($module == 'execution')
{
$executionIds = array_map('intval', explode(',', $this->app->user->view->sprints));
$objectId = max($executionIds);
}
elseif($module == 'task')
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_TASK)
->where('project')->in($this->app->user->view->projects)
->fetch('maxId');
}
elseif($module == 'case')
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_CASE)
->where('project')->in($this->app->user->view->projects)
->orWhere('product')->in($this->app->user->view->products)
->fetch('maxId');
}
elseif($module == 'bug')
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_BUG)
->where('project')->in($this->app->user->view->projects)
->orWhere('product')->in($this->app->user->view->products)
->fetch('maxId');
}
elseif($module == 'doc')
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_DOC)
->where('project')->in($this->app->user->view->projects)
->orWhere('product')->in($this->app->user->view->products)
->fetch('maxId');
if(empty($objectId))
{
$userDocLibs = $this->dao->select('id')->from(TABLE_DOCLIB)
->where('type')->eq('mine')
->andWhere('addedBy')->eq($this->app->user->account)
->fetchPairs();
if(!empty($userDocLibs))
{
$objectId = $this->dao->select('max(id) as maxId')->from(TABLE_DOC)
->where('lib')->in($userDocLibs)
->fetch('maxId');
}
}
}
if(!empty($objectId)) return helper::createLink('ai', 'promptexecute', "promptId=$prompt->id&objectId=$objectId");
return false;
}
/**
* Get target form location of object for prompt.
*
* @param object|int $prompt prompt object or prompt id
* @param object $object
* @param array $linkArgs optional, link arguments, as defined in `->args` of items of `$config->ai->targetFormVars`, e.g. array('story' => 1). if not provided, will try to get from object.
* @access public
* @return array array(link|false, bool), link to target form, and whether php should stop execution and return link.
*/
public function getTargetFormLocation($prompt, $object, $linkArgs = array())
{
if(is_numeric($prompt)) $prompt = $this->getByID($prompt);
if(empty($prompt)) return array(false, true);
$targetForm = $prompt->targetForm;
if(empty($targetForm)) return array(false, true);
if($targetForm === 'empty.empty') return array(false, false);
list($m, $f) = explode('.', $targetForm);
$targetFormConfig = $this->config->ai->targetForm[$m][$f];
$module = strtolower($targetFormConfig->m);
$method = strtolower($targetFormConfig->f);
if($targetForm == 'doc.create')
{
$objectType = '';
$params = '';
$objectData = $object->{$prompt->module};
if(in_array($prompt->module, array('product', 'story', 'productplan', 'release', 'case', 'bug')))
{
$objectType = 'product';
$productID = $prompt->module == 'product' ? $objectData->id : $objectData->product;
$params = helper::safe64Encode("objectID=$productID");
}
if($prompt->module == 'project')
{
$objectType = 'project';
$params = helper::safe64Encode("objectID={$objectData->id}");
}
if($prompt->module == 'execution' || $prompt->module == 'task')
{
$objectType = 'project';
$executionID = $prompt->module == 'execution' ? $objectData->id : $objectData->execution;
$params = helper::safe64Encode("&objectID=$objectData->project&executionID=$executionID");
}
if($prompt->module == 'doc') $objectType = 'mine';
return array(helper::createLink($module, $method, "objectType=$objectType&params=$params&from=ai") . '#open-modal?width=300', false);
}
/* Try to assemble link vars from both passed-in `$linkArgs` and object props. */
$varsConfig = isset($this->config->ai->targetFormVars[$m][$f]) ? $this->config->ai->targetFormVars[$m][$f] : $this->config->ai->targetFormVars[$module][$method];
$vars = array();
foreach($varsConfig->args as $arg => $isRequired)
{
$var = '';
if(!empty($linkArgs[$arg])) // Use provided link args.
{
$var = $linkArgs[$arg];
}
elseif(!empty($object->$arg) && is_object($object->$arg) && !empty($object->$arg->id)) // If the corresponding object exists, use its id.
{
$var = $object->$arg->id;
}
elseif(!empty($object->{$prompt->module}->$arg)) // If object has the prop, use it.
{
$var = $object->{$prompt->module}->$arg;
}
else
{
/* Try to get a related object, we are sorry if it could not find any. */
$relatedObj = $this->tryGetRelatedObjects($prompt, $object, array($arg));
if(!empty($relatedObj))
{
$relatedObj = current($relatedObj);
if(is_string($relatedObj) && strlen($relatedObj) && $relatedObj[0] === ',' && $relatedObj[strlen($relatedObj) - 1] === ',')
{
$relatedObj = explode(',', $relatedObj);
$relatedObj = array_filter($relatedObj);
$relatedObj = current($relatedObj);
}
$var = $relatedObj;
}
}
if(!empty($isRequired) && empty($var)) return array(helper::createLink('ai', 'promptExecutionReset', 'failed=1'), true);
$vars[] = $var;
}
$linkVars = vsprintf($varsConfig->format, $vars);
/* Overrides for stories. */
if($module == 'story' && $method == 'change' && !empty($object->story) && $object->story->status == 'draft') $method = 'edit';
if($module == 'story' && $method == 'change' && !empty($object->story) && $object->story->type == 'epic') $module = 'epic';
return array(helper::createLink($module, $method, $linkVars) . (empty($varsConfig->app) ? '' : "#app=$varsConfig->app"), false);
}
/**
* Get related objects for prompt and object, using object's id.
*
* @param object|int $prompt prompt object or prompt id
* @param object|int $object object or object id
* @param array $objectNames object names to get, e.g. array('story', 'tasks'),
* values in: product, story, branch, productplan, execution, task, bug, case, project, doc
* @access public
* @return array|false returns array of required object values, or false if error.
*/
public function tryGetRelatedObjects($prompt, $object, $objectNames = array())
{
if(empty($objectNames)) return array();
if(is_numeric($prompt)) $prompt = $this->getByID($prompt);
if(empty($prompt)) return false;
if(is_numeric($object)) $object = $this->getObjectForPromptById($prompt, $object);
if(empty($object)) return false;
$vars = array();
/* If a native object of a module exists, try getting stuff related to its id. */
if(!empty($object->{$prompt->module}) && is_object($object->{$prompt->module}) && !empty($object->{$prompt->module}->id))
{
$objectId = $object->{$prompt->module}->id;
foreach($objectNames as $objectName)
{
/* Note that modules are within (product, story, productplan, release, project, execution, task, bug, case, doc). */
switch ($prompt->module)
{
case 'product': // story, branch, productplan, execution, task, bug, case, project, doc
if(in_array($objectName, array('story', 'branch', 'productplan', 'task', 'bug', 'case', 'doc')))
{
$vars[] = $this->dao->select('max(id) as maxId')->from(constant(strtoupper("TABLE_$objectName")))
->where('product')->eq($objectId)
->andWhere('deleted')->eq(0)
->fetch('maxId');
}
elseif($objectName == 'project')
{
$vars[] = $this->dao->select('max(tpp.project) as maxId')->from(TABLE_PROJECTPRODUCT)->alias('tpp')
->leftJoin(TABLE_PROJECT)->alias('tp')->on('tp.id = tpp.project')
->where('tpp.product')->eq($objectId)
->andWhere('tp.type')->eq('project')
->andWhere('tp.deleted')->eq(0)
->fetch('maxId');
}
elseif($objectName == 'execution')
{
$vars[] = $this->dao->select('max(tpp.project) as maxId')->from(TABLE_PROJECTPRODUCT)->alias('tpp')
->leftJoin(TABLE_PROJECT)->alias('tp')->on('tp.id = tpp.project')
->where('tpp.product')->eq($objectId)
->andWhere('tp.project')->ne(0)
->andWhere('tp.deleted')->eq(0)
->fetch('maxId');
}
break;
case 'story': // product, branch, productplan, execution, task, bug, case, project, doc
if($objectName == 'product') $vars[] = $this->dao->select('product')->from(TABLE_STORY)->where('id')->eq($objectId)->fetch('product');
if($objectName == 'branch') $vars[] = $this->dao->select('branch')->from(TABLE_STORY)->where('id')->eq($objectId)->fetch('branch');
if($objectName == 'productplan') $vars[] = $this->dao->select('plan')->from(TABLE_STORY)->where('id')->eq($objectId)->fetch('plan');
if(in_array($objectName, array('task', 'bug', 'case', 'doc')))
{
$vars[] = $this->dao->select('max(id) as maxId')->from(constant(strtoupper("TABLE_$objectName")))->where('story')->eq($objectId)->andWhere('deleted')->eq(0)->fetch('maxId');
}
if($objectName == 'execution')
{
$vars[] = $this->dao->select('tps.project')->from(TABLE_PROJECTSTORY)->alias('tps')
->leftJoin(TABLE_PROJECT)->alias('tp')->on('tp.id = tps.project')
->where('tps.story')->eq($objectId)
->andWhere('tp.project')->ne(0)
->andWhere('tp.deleted')->eq(0)
->fetch('project');
}
if($objectName == 'project')
{
$vars[] = $this->dao->select('tps.project')->from(TABLE_PROJECTSTORY)->alias('tps')
->leftJoin(TABLE_PROJECT)->alias('tp')->on('tp.id = tps.project')
->where('tps.story')->eq($objectId)
->andWhere('tp.type')->eq('project')
->andWhere('tp.deleted')->eq(0)
->fetch('project');
}
break;
case 'productplan': // product, story, branch, execution, task, bug, case, project, doc
if($objectName == 'product') $vars[] = $this->dao->select('product')->from(TABLE_PRODUCTPLAN)->where('id')->eq($objectId)->fetch('product');
if($objectName == 'branch') $vars[] = $this->dao->select('branch')->from(TABLE_PRODUCTPLAN)->where('id')->eq($objectId)->fetch('branch');
if(in_array($objectName, array('story', 'bug')))
{
$vars[] = $this->dao->select('max(id) as maxId')->from(constant(strtoupper("TABLE_$objectName")))->where('plan')->eq($objectId)->andWhere('deleted')->eq(0)->fetch('maxId');
}
if($objectName == 'execution')
{
$vars[] = $this->dao->select('max(tpp.project) as maxId')->from(TABLE_PROJECTPRODUCT)->alias('tpp')
->leftJoin(TABLE_PROJECT)->alias('tp')->on('tp.id = tpp.project')
->where('tpp.plan')->eq($objectId)
->andWhere('tp.project')->ne(0)
->andWhere('tp.deleted')->eq(0)
->fetch('maxId');
}
if($objectName == 'project')
{
$vars[] = $this->dao->select('max(tpp.project) as maxId')->from(TABLE_PROJECTPRODUCT)->alias('tpp')
->leftJoin(TABLE_PROJECT)->alias('tp')->on('tp.id = tpp.project')
->where('tpp.plan')->eq($objectId)
->andWhere('tp.type')->eq('project')
->andWhere('tp.deleted')->eq(0)
->fetch('maxId');
}
if($objectName == 'task') $vars[] = '';
if($objectName == 'case') $vars[] = '';
if($objectName == 'doc') $vars[] = '';
break;
case 'release': // product, story, branch, productplan, execution, task, bug, case, project, doc
if(in_array($objectName, array('product', 'branch', 'project')))
{
$this->dao->select($objectName)->from(TABLE_RELEASE)->where('id')->eq($objectId)->fetch($objectName);
}
if($objectName == 'story')
{
$stories = $this->dao->select('stories')->from(TABLE_RELEASE)->where('id')->eq($objectId)->fetch('stories');
$stories = explode(',', $stories);
$vars[] = empty($stories) ? '' : max($stories);
}
if($objectName == 'productplan')
{
$execution = $this->dao->select('tb.execution')->from(TABLE_BUILD)->alias('tb')
->leftJoin(TABLE_RELEASE)->alias('tr')->on('tr.build = tb.id')
->where('tr.id')->eq($objectId)
->fetch('tb.execution');
$vars[] = empty($execution) ? '' : $this->dao->select('max(tpp.plan) as maxId')->from(TABLE_PROJECTPRODUCT)->alias('tpp')
->leftJoin(TABLE_PROJECT)->alias('tp')->on('tp.id = tpp.project')
->where('tp.id')->eq($execution)
->andWhere('tp.deleted')->eq(0)
->fetch('maxId');
}
if($objectName == 'execution')
{
$vars[] = $this->dao->select('tb.execution')->from(TABLE_BUILD)->alias('tb')
->leftJoin(TABLE_RELEASE)->alias('tr')->on('tr.build = tb.id')
->where('tr.id')->eq($objectId)
->fetch('execution');
}
if($objectName == 'bug')
{
$bugs = $this->dao->select('bugs')->from(TABLE_RELEASE)->where('id')->eq($objectId)->fetch('bugs');
$bugs = explode(',', $bugs);
$vars[] = empty($bugs) ? '' : max($bugs);
}
if($objectName == 'task') $vars[] = '';
if($objectName == 'case') $vars[] = '';
if($objectName == 'doc') $vars[] = '';
break;
case 'project': // product, story, branch, productplan, execution, task, bug, case, doc
if(in_array($objectName, array('product', 'branch', 'productplan')))
{
if($objectName == 'productplan') $objectName = 'plan';
$vars[] = $this->dao->select($objectName)->from(TABLE_PROJECTPRODUCT)->where('project')->eq($objectId)->fetch($objectName);
}
if(in_array($objectName, array('execution', 'bug', 'task', 'case', 'doc')))
{
$vars[] = $this->dao->select('max(id) as maxId')->from(constant(strtoupper("TABLE_$objectName")))->where('project')->eq($objectId)->andWhere('deleted')->eq(0)->fetch('maxId');
}
if($objectName == 'story')
{
$vars[] = $this->dao->select('max(tps.story) as maxId')->from(TABLE_PROJECTSTORY)->alias('tps')
->leftJoin(TABLE_STORY)->alias('ts')->on('ts.id = tps.story')
->where('tps.project')->eq($objectId)
->andWhere('ts.deleted')->eq(0)
->fetch('maxId');
}
break;
case 'execution': // product, story, branch, productplan, task, bug, case, project, doc
if($objectName == 'project') $vars[] = $this->dao->select('project')->from(TABLE_EXECUTION)->where('id')->eq($objectId)->fetch('project');
if($objectName == 'product') $vars[] = $this->dao->select('product')->from(TABLE_PROJECTPRODUCT)->where('project')->eq($objectId)->fetch('product');
if($objectName == 'story') $vars[] = $this->dao->select('max(story) as maxId')->from(TABLE_PROJECTSTORY)->where('project')->eq($objectId)->fetch('maxId');
if($objectName == 'branch') $vars[] = $this->dao->select('max(branch) as maxId')->from(TABLE_PROJECTSTORY)->where('project')->eq($objectId)->fetch('maxId');
if($objectName == 'productplan') $vars[] = $this->dao->select('plan')->from(TABLE_PROJECTPRODUCT)->where('project')->eq($objectId)->fetch('plan');
if($objectName == 'task') $vars[] = $this->dao->select('max(id) as maxId')->from(TABLE_TASK)->where('execution')->eq($objectId)->andWhere('deleted')->eq(0)->fetch('maxId');
if($objectName == 'bug') $vars[] = $this->dao->select('max(id) as maxId')->from(TABLE_BUG)->where('project')->eq($objectId)->andWhere('deleted')->eq(0)->fetch('maxId');
if($objectName == 'case') $vars[] = $this->dao->select('max(id) as maxId')->from(TABLE_CASE)->where('project')->eq($objectId)->andWhere('deleted')->eq(0)->fetch('maxId');
if($objectName == 'doc') $vars[] = $this->dao->select('max(id) as maxId')->from(TABLE_DOC)->where('project')->eq($objectId)->andWhere('deleted')->eq(0)->fetch('maxId');
break;
case 'task': // product, story, branch, productplan, execution, bug, case, project, doc
if($objectName == 'product')
{
$vars[] = $this->dao->select('tpp.product')->from(TABLE_TASK)->alias('tt')
->leftJoin(TABLE_PROJECTPRODUCT)->alias('tpp')->on('tpp.project = tt.project')
->where('tt.id')->eq($objectId)
->fetch('product');
}
if($objectName == 'story') $vars[] = $this->dao->select('story')->from(TABLE_TASK)->where('id')->eq($objectId)->fetch('story');
if($objectName == 'branch')
{
$vars[] = $this->dao->select('tpp.branch')->from(TABLE_TASK)->alias('tt')
->leftJoin(TABLE_PROJECTPRODUCT)->alias('tpp')->on('tpp.project = tt.project')
->where('tt.id')->eq($objectId)
->fetch('branch');
}
if($objectName == 'productplan')
{
$vars[] = $this->dao->select('tpp.plan')->from(TABLE_TASK)->alias('tt')
->leftJoin(TABLE_PROJECTPRODUCT)->alias('tpp')->on('tpp.project = tt.project')
->where('tt.id')->eq($objectId)
->fetch('plan');
}
if($objectName == 'execution') $vars[] = $this->dao->select('execution')->from(TABLE_TASK)->where('id')->eq($objectId)->fetch('execution');
if($objectName == 'bug') $vars[] = $this->dao->select('max(id) as maxId')->from(TABLE_BUG)->where('task')->eq($objectId)->andWhere('deleted')->eq(0)->fetch('maxId');
if($objectName == 'case') $vars[] = '';
if($objectName == 'doc') $vars[] = '';
break;
case 'bug': // product, story, branch, productplan, execution, task, case, project, doc
if(in_array($objectName, array('product', 'branch', 'productplan', 'execution', 'task', 'case', 'story', 'project')))
{
if($objectName == 'productplan') $objectName = 'plan';
$vars[] = $this->dao->select($objectName)->from(TABLE_BUG)->where('id')->eq($objectId)->fetch($objectName);
}
if($objectName == 'doc') $vars[] = '';
break;
case 'case': // product, story, branch, productplan, execution, task, bug, project, doc
if(in_array($objectName, array('product', 'story', 'branch', 'execution', 'project')))
{
$vars[] = $this->dao->select($objectName)->from(TABLE_CASE)->where('id')->eq($objectId)->fetch($objectName);
}
if($objectName == 'productplan')
{
$vars[] = $this->dao->select('tpp.plan')->from(TABLE_CASE)->alias('tc')
->leftJoin(TABLE_PROJECTPRODUCT)->alias('tpp')->on('tpp.project = tc.project')
->where('tc.id')->eq($objectId)
->fetch('plan');
}
if($objectName == 'task') $vars[] = $this->dao->select('max(task) as maxId')->from(TABLE_BUG)->where('case')->eq($objectId)->andWhere('deleted')->eq(0)->fetch('maxId');
if($objectName == 'bug') $vars[] = $this->dao->select('max(id) as maxId')->from(TABLE_BUG)->where('case')->eq($objectId)->andWhere('deleted')->eq(0)->fetch('maxId');
if($objectName == 'doc') $vars[] = '';
break;
case 'doc': // product, story, branch, productplan, execution, task, bug, case, project
if(in_array($objectName, array('product', 'execution', 'project')))
{
$vars[] = $this->dao->select($objectName)->from(TABLE_DOC)->where('id')->eq($objectId)->fetch($objectName);
}
if($objectName == 'story')
{
$vars[] = $this->dao->select('max(id) as maxId')->from(TABLE_STORY)->alias('ts')
->leftJoin(TABLE_DOC)->alias('td')->on('td.product = ts.product')
->where('td.id')->eq($objectId)
->andWhere('ts.deleted')->eq(0)
->fetch('maxId');
}
if($objectName == 'branch')
{
$vars[] = $this->dao->select('branch')->from(TABLE_STORY)->alias('ts')
->leftJoin(TABLE_DOC)->alias('td')->on('td.product = ts.product')
->where('td.id')->eq($objectId)
->andWhere('ts.deleted')->eq(0)
->fetch('branch');
}
if($objectName == 'productplan')
{
$vars[] = $this->dao->select('plan')->from(TABLE_STORY)->alias('ts')
->leftJoin(TABLE_DOC)->alias('td')->on('td.product = ts.product')
->where('td.id')->eq($objectId)
->andWhere('ts.deleted')->eq(0)
->fetch('plan');
}
if($objectName == 'task')
{
$vars[] = $this->dao->select('max(id) as maxId')->from(TABLE_TASK)->alias('tt')
->leftJoin(TABLE_DOC)->alias('td')->on('td.project = tt.project or td.execution = tt.execution')
->where('td.id')->eq($objectId)
->andWhere('tt.deleted')->eq(0)
->fetch('maxId');
}
if($objectName == 'bug')
{
$vars[] = $this->dao->select('max(id) as maxId')->from(TABLE_BUG)->alias('tb')
->leftJoin(TABLE_DOC)->alias('td')->on('td.product = tb.product')
->where('td.id')->eq($objectId)
->andWhere('tb.deleted')->eq(0)
->fetch('maxId');
}
if($objectName == 'case')
{
$vars[] = $this->dao->select('max(id) as maxId')->from(TABLE_CASE)->alias('tc')
->leftJoin(TABLE_DOC)->alias('td')->on('td.product = tc.product')
->where('td.id')->eq($objectId)
->andWhere('tc.deleted')->eq(0)
->fetch('maxId');
}
break;
default:
$vars[] = '';
}
}
}
return $vars;
}
/**
* Get the last active step of prompt by id.
*
* @param object $prompt
* @access public
* @return string
*/
public function getLastActiveStep($prompt)
{
if(!empty($prompt))
{
if($prompt->status == 'active') return 'finalize';
if(!empty($prompt->targetForm)) return 'settargetform';
if(!empty($prompt->purpose)) return 'setpurpose';
if(!empty($prompt->source)) return 'selectdatasource';
}
return 'assignrole';
}
/**
* Get prompts available for calling (which are either active or created by the user) of a module for user.
*
* @param string $module
* @access public
* @return array
*/
public function getPromptsForUser($module)
{
return $this->dao->select('*')->from(TABLE_AI_AGENT)
->where('deleted')->eq(0)
->andWhere('module')->eq($module)
->andWhere('status')->eq('active')
->orderBy('id_desc')
->fetchAll('id', false);
}
/**
* Filter prompts by user's privilege and executable state.
*
* @param array $prompts
* @param bool $keepUnauthorized optional, whether to keep unauthorized prompts but set their `unauthorized` property to true.
* @access public
* @return array filtered prompts, those unauthorized will be removed if `$keepUnauthorized` is false, unexecutable ones will always be removed.
*/
public function filterPromptsForExecution($prompts, $keepUnauthorized = false)
{
if(empty($prompts)) return array();
/* Remove the unexecutable ones. */
$prompts = array_filter($prompts, array($this, 'isExecutable'));
/* Check user's priv to targetForm. */
foreach($prompts as $idx => $prompt)
{
list($m, $f) = explode('.', $prompt->targetForm);
if($m === 'empty' && $f === 'empty') continue;
$targetFormConfig = $this->config->ai->targetForm[$m][$f];
if(empty($targetFormConfig))
{
unset($prompts[$idx]);
continue;
}
if(!commonModel::hasPriv($targetFormConfig->m, $targetFormConfig->f))
{
if($keepUnauthorized)
{
$prompts[$idx]->unauthorized = true;
}
else
{
unset($prompts[$idx]);
}
}
}
return array_values($prompts);
}
/**
* 获取禅道智能体的测试数据。
* Get prompt test data.
*
* @param object $prompt
* @access public
* @return array
*/
public function getTestPromptData(object $prompt): array
{
$module = $prompt->module;
$promptSource = $prompt->source;
$source = explode(',', $promptSource);
$source = array_filter($source, function($value) {return !empty($value);});
$titleData = $this->lang->ai->dataSource[$module];
$testData = $this->lang->ai->prompts->testData[$module];
$categorized = array();
foreach($source as $value)
{
$prefix = explode('.', $value)[0];
$column = explode('.', $value)[1];
if(!isset($categorized[$prefix])) $categorized[$prefix] = [];
$categorized[$prefix][] = $column;
}
$result = '';
foreach($categorized as $groupKey => $pathInfo)
{
if(in_array($groupKey, array('programplans', 'executions', 'stories', 'bugs', 'tasks', 'steps')))
{
if($module == 'release' && $groupKey == 'bugs')
{
$result .= '##### ' . $titleData[$groupKey]['common'] . $this->lang->colon . "\n";
$result .= $testData[$groupKey]['title'] . "\n";
}
else
{
$result .= '##### ' . $titleData[$groupKey]['common'] . $this->lang->colon . "\n";
$result .= "| ";
foreach($pathInfo as $value) $result .= $titleData[$groupKey][$value] . " | ";
$result .= "\n";
$result .= "| ";
foreach($pathInfo as $value) $result .= "--- | ";
$result .= "\n";
$firstData = $pathInfo[0];
$count = count($testData[$groupKey][$firstData]);
for($i = 0; $i < $count; $i++)
{
$result .= "| ";
foreach($pathInfo as $value) $result .= $testData[$groupKey][$value][$i] . " | ";
$result .= "\n";
}
}
}
else
{
foreach($pathInfo as $value)
{
if($groupKey == 'task' && $value == 'story') continue;
$result .= '##### ' . $titleData[$groupKey][$value] . $this->lang->colon . "\n";
$result .= $testData[$groupKey][$value] . "\n";
}
}
}
return array($testData, $result);
}
/**
* Set inject data for a form. For how injection works, see view/inputinject.html.php file.
*
* @param string|array $form 'module.method' or array('module', 'method').
* @param string|object $data data to inject, object will be json encoded.
* @access public
* @return void
*/
public function setInjectData($form, $data)
{
if(is_string($form)) $form = explode('.', $form);
$targetForm = $this->config->ai->targetForm[$form[0]][$form[1]];
if(empty($targetForm)) return;
/* Override method for story drafts. */
if($targetForm->m == 'story' && $targetForm->f == 'change') $_SESSION['aiInjectData']['story']['edit'] = is_string($data) ? $data : json_encode($data);
$_SESSION['aiInjectData'][$targetForm->m][$targetForm->f] = is_string($data) ? $data : json_encode($data);
}
/**
* Get role templates from db.
*
* @access public
* @return array
*/
public function getRoleTemplates()
{
return $this->dao->select('*')->from(TABLE_AI_AGENTROLE)
->where('deleted')->eq(0)
->fetchAll('id', false);
}
/**
* add role template.
*
* @param string $role
* @param string $characterization
* @access public
* @return false|int
*/
public function createRoleTemplate($role, $characterization)
{
$roleTemplate = new stdclass();
$roleTemplate->role = $role;
$roleTemplate->characterization = $characterization;
$this->dao->insert(TABLE_AI_AGENTROLE)
->data($roleTemplate)
->exec();
if(dao::isError()) return false;
return $this->dao->lastInsertID();
}
/**
* Delete a role template.
*
* @param int $id
* @access public
* @return false|int
*/
public function deleteRoleTemplate($id)
{
$this->dao->update(TABLE_AI_AGENTROLE)
->set('deleted')->eq(1)
->where('id')->eq($id)
->exec();
if(dao::isError()) return false;
return true;
}
/**
* Update a role template.
*
* @param string $role
* @param string $characterization
* @access public
* @return bool
*/
public function updateRoleTemplate($id, $role, $characterization)
{
$roleTemplate = new stdclass();
$roleTemplate->role = $role;
$roleTemplate->characterization = $characterization;
$this->dao->update(TABLE_AI_AGENTROLE)
->data($roleTemplate)
->where('id')->eq($id)
->exec();
if(dao::isError()) return false;
return true;
}
/**
* Get assistants.
* @param pager $pager
* @param string $orderBy
* @access public
* @return array
*/
public function getAssistants($pager = null, $orderBy = 'id_desc')
{
return $this->dao->select('*')->from(TABLE_AI_ASSISTANT)
->where('deleted')->eq('0')
->orderBy($orderBy)
->page($pager)
->fetchAll('id', false);
}
public function getAssistantsByModel($modelId, $enabled = true)
{
return $this->dao->select('*')->from(TABLE_AI_ASSISTANT)
->where('modelId')->eq($modelId)
->andWhere('deleted')->eq('0')
->andWhere('enabled')->eq($enabled ? '1' : '0')
->fetchAll('id', false);
}
/**
* Get assistant by id.
* @param int $assistantId
* @access public
* @return object
*/
public function getAssistantById($assistantId)
{
return $this->dao->select('*')->from(TABLE_AI_ASSISTANT)
->where('id')->eq($assistantId)
->fetch();
}
/**
* create an assistant.
* @param object $assistant
* @access public
* @return bool
*/
public function createAssistant($assistant, $publish = false)
{
$assistant->createdDate = helper::now();
if($publish)
{
$assistant->publishedDate = helper::now();
$assistant->enabled = '1';
}
else
{
$assistant->enabled = '0';
}
$this->dao
->insert(TABLE_AI_ASSISTANT)
->data($assistant)
->exec();
if (dao::isError()) return false;
$assistantId = $this->dao->lastInsertID();
$this->loadModel('action')->create('aiAssistant', $assistantId, 'created');
if($publish)
{
$this->loadModel('action')->create('aiAssistant', $assistantId, 'online');
}
return true;
}
/**
* update an assistant.
* @param object $assistant
* @access public
* @return bool
*/
public function updateAssistant($assistant)
{
$originAssistant = $this->getAssistantById($assistant->id);
$actionType = 'edited';
if(!empty($originAssistant))
{
$changedFields = array();
foreach($assistant as $key => $value)
{
if($value != $originAssistant->$key) $changedFields[] = $key;
}
if(count($changedFields) == 1 && current($changedFields) == 'status')
{
$actionType = $assistant->enabled == '0' ? 'offline' : 'online';
}
else
{
$changes = commonModel::createChanges($originAssistant, $assistant);
}
}
foreach ($assistant as $key => $value)
{
if(isset($originAssistant->$key)) $originAssistant->$key = $value;
}
if(empty($originAssistant->publishedDate))
{
unset($originAssistant->publishedDate);
}
$this->dao->update(TABLE_AI_ASSISTANT)
->data($originAssistant)
->where('id')->eq($assistant->id)
->exec();
if(dao::isError()) return false;
if(!empty($changes))
{
$actionId = $this->loadModel('action')->create('aiAssistant', $assistant->id, $actionType);
$this->action->logHistory($actionId, $changes);
}
return true;
}
/**
* Toggle an assistant.
* @param int $assistantId
* @param bool $enabled
* @access public
* @return bool
*/
public function toggleAssistant($assistantId, $enabled)
{
$this->dao->update(TABLE_AI_ASSISTANT)
->set('enabled')->eq($enabled ? '1' : '0')
->set('publishedDate')->eq($enabled ? helper::now() : null)
->where('id')->eq($assistantId)
->exec();
if(dao::isError()) return false;
$actionType = $enabled ? 'online' : 'offline';
$this->loadModel('action')->create('aiAssistant', $assistantId, $actionType);
return true;
}
/**
* Find assistants name is duplicate.
* @param string $AssistantName
* @param int $modelId
* @access public
* @return object
*/
public function checkAssistantDuplicate($AssistantName, $modelId)
{
return $this->dao->select('*')->from(TABLE_AI_ASSISTANT)
->where('name')->eq($AssistantName)
->andWhere('modelId')->eq($modelId)
->andWhere('deleted')->eq('0')
->fetch();
}
public function deleteAssistant($assistantId)
{
$this->dao->update(TABLE_AI_ASSISTANT)
->set('deleted')->eq('1')
->where('id')->eq($assistantId)
->exec();
if(dao::isError()) return false;
$this->loadModel('action')->create('aiAssistant', $assistantId, 'deleted');
return true;
}
}
/**
* Exception class for response from AI.
*/
class AIResponseException extends Exception
{
/**
* Type of response error, error messages are defined in lang files with type as key.
*
* See `$lang->ai->aiResponseException`.
*
* @var string
* @access public
*/
public $type;
/**
* Response with error.
*
* @var string|object
* @access public
*/
public $response;
/**
* Create a AIResponseException, load error message from lang file.
*
* @param string $type
* @param string|object $response
* @access public
* @return void
*/
public function __construct($type, $response)
{
global $app;
$this->type = $type;
$this->response = $response;
$this->message = zget($app->lang->ai->aiResponseException, $type, '');
}
}