1
0
mirror of https://github.com/typemill/typemill.git synced 2025-07-31 03:10:19 +02:00

Add claude ai service

This commit is contained in:
trendschau
2025-04-01 21:15:28 +02:00
parent 3936e7845d
commit c27b024fe3
6 changed files with 264 additions and 111 deletions

2
cache/timer.yaml vendored
View File

@@ -1 +1 @@
licenseupdate: 1743349357
licenseupdate: 1743531622

View File

@@ -15,6 +15,19 @@ class ControllerApiKixote extends Controller
{
private $error = false;
private function getSystemMessage()
{
$system = 'You are a content editor and writing assistant.'
. ' If the user prompt does not explicitly specify otherwise,'
. ' apply the prompt to the provided article and return only the updated article in Markdown syntax,'
. ' without any extra comments or explanations.'
. ' If you find the tag <focus></focus>,'
. ' modify only the content inside these tags and leave everything else unchanged.'
. ' Always return the full article.';
return $system;
}
public function getKixoteSettings(Request $request, Response $response)
{
$settingsModel = new Settings();
@@ -35,7 +48,6 @@ class ControllerApiKixote extends Controller
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
}
public function updateKixoteSettings(Request $request, Response $response)
@@ -247,6 +259,199 @@ class ControllerApiKixote extends Controller
return $jwt;
}
public function prompt(Request $request, Response $response)
{
$params = $request->getParsedBody();
if (empty($params['prompt']) || !is_string($params['prompt']))
{
$response->getBody()->write(json_encode([
'message' => 'Prompt is missing or invalid.'
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
if (empty($params['article']) || !is_string($params['article']))
{
$response->getBody()->write(json_encode([
'message' => 'Article is missing or invalid.'
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
$promptname = $params['name'] ?? '';
$prompt = $params['prompt'] ?? '';
$article = $params['article'] ?? '';
$tone = $params['tone'] ?? '';
$aiservice = $this->settings['aiservice'] ?? false;
if(!$aiservice)
{
$response->getBody()->write(json_encode([
'message' => 'No ai service is selected.'
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
switch ($aiservice) {
case 'chatgpt':
$answer = $this->promptChatGPT($promptname, $prompt, $article, $tone);
break;
case 'claude':
$answer = $this->promptClaude($promptname, $prompt, $article, $tone);
break;
default:
$answer = false;
break;
}
if(!isset($answer) or !$answer)
{
$response->getBody()->write(json_encode([
'message' => $this->error
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
$response->getBody()->write(json_encode([
'message' => 'Success',
'answer' => $answer,
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
}
public function promptChatGPT($promptname, $prompt, $article, $tone)
{
# check if user has accepted
$settingsModel = new Settings();
$model = $this->settings['chatgptModel'] ?? false;
$apikey = $settingsModel->getSecret('chatgptKey');
if (!$model || !$apikey)
{
$this->error = 'Model or api key for chatgpt is missing, please add it in the system settings.';
return false;
}
$url = 'https://api.openai.com/v1/chat/completions';
$authHeader = "Authorization: Bearer $apikey";
$postdata = [
'model' => $model,
'messages' => [
[
'role' => 'system',
'content' => $this->getSystemMessage(),
],
[
'role' => 'user',
'content' => $prompt . "\n" . $article
],
],
'temperature' => 0.7,
'max_tokens' => 8000,
];
$apiservice = new ApiCalls();
$apiResponse = $apiservice->makePostCall($url, $postdata, $authHeader);
if (!$apiResponse)
{
$this->error = 'Failed to communicate with ChatGPT: ' . $apiservice->getError();
return false;
}
$data = json_decode($apiResponse, true);
if(isset($data['error']))
{
$this->error = 'ChatGPT returned and error';
if(isset($data['error']['message']))
{
$this->error = $data['error']['message'];
}
return false;
}
if (!isset($data['choices'][0]['message']['content']) || !is_string($data['choices'][0]['message']['content']))
{
$this->error = 'ChatGPT did not return a valid answer.';
return false;
}
$answer = trim($data['choices'][0]['message']['content']);
return $answer;
}
public function promptClaude($promptname, $prompt, $article, $tone)
{
# Check if user has accepted
$settingsModel = new Settings();
$model = $this->settings['claudeModel'] ?? false;
$apikey = $settingsModel->getSecret('claudeKey');
if (!$model || !$apikey)
{
$this->error = 'Model or API key for Claude is missing, please add it in the system settings.';
return false;
}
$url = 'https://api.anthropic.com/v1/messages';
$headers = [
"x-api-key: $apikey",
"anthropic-version: 2023-06-01"
];
$postdata = [
'model' => $model,
'system' => $this->getSystemMessage(),
'messages' => [
[
'role' => 'user',
'content' => $prompt . "\n" . $article
],
],
'temperature' => 0.7,
'max_tokens' => 8000,
];
$apiservice = new ApiCalls();
$apiResponse = $apiservice->makePostCall($url, $postdata, $headers);
if (!$apiResponse) {
$this->error = 'Failed to communicate with Claude: ' . $apiservice->getError();
return false;
}
$data = json_decode($apiResponse, true);
if (isset($data['error']))
{
$this->error = 'Claude API returned an error';
if (isset($data['error']['message']))
{
$this->error = $data['error']['message'];
}
return false;
}
if (!isset($data['content'][0]['text']) || !is_string($data['content'][0]['text']))
{
$this->error = 'Claude did not return a valid answer.';
return false;
}
return trim($data['content'][0]['text']);
}
# NOT READY YET
public function promptKixote(Request $request, Response $response)
{
$jwt = $this->getKixoteJWT();
@@ -296,84 +501,4 @@ class ControllerApiKixote extends Controller
return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
}
public function promptChatGPT(Request $request, Response $response): Response
{
# check if user has accepted
$params = $request->getParsedBody();
$params['name'] = $params['name'] ?? '';
$params['prompt'] = $params['prompt'] ?? '';
$params['article'] = $params['article'] ?? '';
$params['tone'] = $params['tone'] ?? '';
$settingsModel = new Settings();
$model = $this->settings['chatgptModel'] ?? false;
$apikey = $settingsModel->getSecret('chatgptKey');
if (empty($params['prompt']) || !is_string($params['prompt']))
{
$response->getBody()->write(json_encode([
'message' => 'Prompt is missing or invalid.'
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
if (empty($params['article']) || !is_string($params['article']))
{
$response->getBody()->write(json_encode([
'message' => 'Article is missing or invalid.'
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
if (!$model || !$apikey)
{
$response->getBody()->write(json_encode([
'message' => 'Model or api key for chatgpt is missing, please add it in the system settings.'
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
$url = 'https://api.openai.com/v1/chat/completions';
$authHeader = "Authorization: Bearer $apikey";
$postdata = [
'model' => $model,
'messages' => [
[
'role' => 'system',
'content' => 'You are a content editor and writing assistant. If the user prompt does not explicitly specify otherwise, apply the prompt to the provided article and return only the updated article in Markdown syntax, without any extra comments or explanations. If you find the tag <focus></focus>, modify only the content inside these tags and leave everything else unchanged. Always return the full article.'
],
[
'role' => 'user',
'content' => $params['prompt'] . "\n" . $params['article']
],
],
'temperature' => 0.7,
'max_tokens' => 2000,
];
$apiservice = new ApiCalls();
$apiResponse = $apiservice->makePostCall($url, $postdata, $authHeader);
if (!$apiResponse)
{
$response->getBody()->write(json_encode([
'message' => 'Failed to communicate with ChatGPT',
'error' => $apiservice->getError()
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
$data = json_decode($apiResponse, true);
$response->getBody()->write(json_encode([
'message' => 'Success',
'data' => $data,
]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
}
}

View File

@@ -31,7 +31,7 @@ class ApiCalls
return $this->makeFileGetContentsCall($url, 'GET', null, $authHeader);
}
private function makeCurlCall($url, $method, $data = false, $authHeader = '')
private function makeCurlCall($url, $method, $data = false, $customHeader = '')
{
$this->error = null;
@@ -39,10 +39,7 @@ class ApiCalls
"Content-Type: application/json",
];
if (!empty($authHeader))
{
$headers[] = $authHeader;
}
$headers = $this->addCustomHeader($customHeader);
$curl = curl_init($url);
if (defined('CURLSSLOPT_NATIVE_CA') && version_compare(curl_version()['version'], '7.71', '>='))
@@ -78,7 +75,7 @@ class ApiCalls
return $response;
}
private function makeFileGetContentsCall($url, $method, $data = null, $authHeader = '')
private function makeFileGetContentsCall($url, $method, $data = null, $customHeader = '')
{
$this->error = null;
@@ -86,10 +83,7 @@ class ApiCalls
"Content-Type: application/json"
];
if (!empty($authHeader))
{
$headers[] = $authHeader;
}
$headers = $this->addCustomHeader($headers, $customHeader);
$options = [
'http' => [
@@ -133,4 +127,24 @@ class ApiCalls
return $response;
}
}
private function addCustomHeader(array $header, $customHeader = '')
{
if(!empty($customHeader))
{
if(is_array($customHeader))
{
foreach($customHeader as $cHeader)
{
$header[] = $cHeader;
}
}
elseif(is_string($customHeader))
{
$header[] = $customHeader;
}
}
return $header;
}
}

View File

@@ -332,6 +332,7 @@ kixote.component('tab-generate', {
activeversion: 0,
versions: [],
prompt: '',
promptError: false,
showFocusButton: false,
buttonPosition: { top: 0, left: 0 },
selection: { start: 0, end: 0, text: '' },
@@ -456,6 +457,7 @@ kixote.component('tab-generate', {
</div>
<!-- PROMPT INPUT -->
<div v-if="promptError" class="w-full px-8 py-1 bg-rose-500 text-white">{{ promptError }}</div>
<div class="w-full bg-stone-600 px-8 py-2">
<div class="flex items-start">
<span class="text-teal-300 mr-1">Ki></span>
@@ -643,7 +645,6 @@ kixote.component('tab-generate', {
currentTab(newTab, oldTab) {
if (newTab === 'article')
{
console.info('article');
this.$nextTick(() => {
this.initAutosize(); // Trigger the resizing when switching back to the article tab
});
@@ -710,12 +711,12 @@ kixote.component('tab-generate', {
{
eventBus.$emit('agreetoservice');
self.$nextTick(() => {
self.resizeAiEditor();
self.initAutosize();
});
})
},
setCurrentTab(tabValue)
{
{
this.currentTab = tabValue;
if(tabValue == 'article')
@@ -768,10 +769,12 @@ kixote.component('tab-generate', {
},
submitPrompt()
{
this.promptError = false;
var self = this;
eventBus.$emit('switchLoading');
tmaxios.post('/api/v1/chatgpt',{
tmaxios.post('/api/v1/prompt',{
'prompt': this.prompt,
'article': this.versions[this.activeversion]
})
@@ -780,7 +783,7 @@ kixote.component('tab-generate', {
eventBus.$emit('switchLoading');
if (response.data.message === 'Success')
{
let answer = response.data.data.choices[0].message.content;
let answer = response.data.answer;
answer = answer.replace(/<\/?focus>/g, '');
self.versions.push(answer);
self.activeversion = self.versions.length-1;
@@ -795,12 +798,11 @@ kixote.component('tab-generate', {
if(error.response)
{
self.disabled = false;
self.message = handleErrorMessage(error);
self.messageClass = 'bg-rose-500';
self.promptError = handleErrorMessage(error);
self.licensemessage = error.response.data.message;
if(error.response.data.errors !== undefined)
{
self.errors = error.response.data.errors;
self.promptError = error.response.data.errors;
}
}
});

View File

@@ -99,7 +99,7 @@ $app->group('/api/v1', function (RouteCollectorProxy $group) use ($acl) {
# KIXOTE Remote Services
$group->get('/tokenstats', ControllerApiKixote::class . ':getTokenStats')->setName('api.kixote.tokenstats')->add(new ApiAuthorization($acl, 'mycontent', 'update')); # author
$group->post('/agreetoaiservice', ControllerApiKixote::class . ':agreeToAiService')->setName('api.kixote.serviceagreement')->add(new ApiAuthorization($acl, 'mycontent', 'update')); # author
$group->post('/chatgpt', ControllerApiKixote::class . ':promptChatGPT')->setName('api.kixote.chatgpt')->add(new ApiAuthorization($acl, 'mycontent', 'update')); # author
$group->post('/prompt', ControllerApiKixote::class . ':prompt')->setName('api.kixote.prompt')->add(new ApiAuthorization($acl, 'mycontent', 'update')); # author
# API USED ONLY EXTERNALLY
$group->get('/systemnavi', ControllerApiGlobals::class . ':getSystemnavi')->setName('api.systemnavi.get')->add(new ApiAuthorization($acl, 'account', 'read')); # member

View File

@@ -325,23 +325,35 @@ fieldsetai:
none: 'None'
# kixote: 'Kixote (not available yet)'
chatgpt: 'ChatGPT'
claude: 'Claude'
chatgptModel:
type: select
label: 'ChatGPT model'
description: "Select the AI model you want to use. Pricing may vary over time, so please check the latest details on [OpenAI's pricing page](https://platform.openai.com/docs/pricing)"
options:
gpt-4o-mini: 'gpt-4o-mini ($0.6 / 1M output tokens)'
gpt-3.5-turbo-0125: 'gpt-3.5-turbo-0125 ($1.50 / 1M output tokens)'
gpt-4o: 'gpt-4o ($10.00 / 1M output tokens)'
o1-mini: 'o1-mini ($12.00 / 1M output tokens)'
o1: 'o1 ($60.00 / 1M output tokens)'
gpt-4o-mini: 'gpt-4o-mini ($0.15 / $0.6)'
gpt-4o: 'gpt-4o ($2.50 / $10)'
gpt-3.5-turbo-0125: 'gpt-3.5-turbo ($0.50 / $1.50)'
o1-mini: 'o1-mini ($1.10 / 4.40)'
o1: 'o1 ($15 / $60.00)'
chatgptKey:
type: password
autocomplete: new-password
generator: false
label: 'ChatGPT Api Key'
description: "Enter your ChatGPT API key here. You can generate a new key on [OpenAI's platform](https://platform.openai.com/docs/pricing). For security reasons, your API key is secret and will not be visible again after you leave this page."
customsmall:
type: customfields
label: 'Customfield Small'
description: "This is a small standard customfield."
claudeModel:
type: select
label: 'Claude model'
description: "Select the AI model you want to use. Pricing may vary over time, so please check the latest details on [Claude's pricing page](https://docs.anthropic.com/en/docs/about-claude/models/all-models)"
options:
claude-3-7-sonnet-20250219: 'Claude 3.7 Sonnet ($3 / $15)'
claude-3-5-haiku-20241022: 'Claude 3.5 Haiku ($0.80 / $4)'
claude-3-opus-20240229: 'Claude 3 Opus ($15.00 / $75)'
claude-3-haiku-20240307: 'Claude 3 Haiku ($0.25 / $1.25)'
claudeKey:
type: password
autocomplete: new-password
generator: false
label: 'Claude Api Key'
description: "Enter your Claude API key here. You can generate a new key on [Claude's console](https://console.anthropic.com). For security reasons, your API key is secret and will not be visible again after you leave this page."