diff --git a/htdocs/adherents/class/adherent.class.php b/htdocs/adherents/class/adherent.class.php index 849516e3971..7dfe24e7e82 100644 --- a/htdocs/adherents/class/adherent.class.php +++ b/htdocs/adherents/class/adherent.class.php @@ -2181,9 +2181,10 @@ class Adherent extends CommonObject * Function that validate a member * * @param User $user user adherent qui valide + * @param int $notrigger 1=disable trigger UPDATE (when called by create) * @return int Return integer <0 if KO, 0 if nothing done, >0 if OK */ - public function validate($user) + public function validate($user, $notrigger = 0) { global $langs, $conf; @@ -2211,11 +2212,13 @@ class Adherent extends CommonObject $this->status = self::STATUS_VALIDATED; // Call trigger - $result = $this->call_trigger('MEMBER_VALIDATE', $user); - if ($result < 0) { - $error++; - $this->db->rollback(); - return -1; + if (!$notrigger) { + $result = $this->call_trigger('MEMBER_VALIDATE', $user); + if ($result < 0) { + $error++; + $this->db->rollback(); + return -1; + } } // End call triggers diff --git a/htdocs/adherents/class/subscription.class.php b/htdocs/adherents/class/subscription.class.php index 8dc2532b732..390cf90b213 100644 --- a/htdocs/adherents/class/subscription.class.php +++ b/htdocs/adherents/class/subscription.class.php @@ -440,12 +440,14 @@ class Subscription extends CommonObject $result = ''; $langs->load("members"); + $langs->load("main"); $label = img_picto('', $this->picto).' '.$langs->trans("Subscription").''; /*if (isset($this->statut)) { $label .= ' '.$this->getLibStatut(5); }*/ $label .= '
'.$langs->trans('Ref').': '.$this->ref; + $label .= '
'.$langs->trans('Label').': '.$this->note_public; if (!empty($this->dateh)) { $label .= '
'.$langs->trans('DateStart').': '.dol_print_date($this->dateh, 'day'); } diff --git a/htdocs/adherents/list.php b/htdocs/adherents/list.php index 800ac22cf92..c2d23969900 100644 --- a/htdocs/adherents/list.php +++ b/htdocs/adherents/list.php @@ -356,6 +356,7 @@ if (empty($reshook)) { if ($action == 'createsubscription_confirm' && $confirm == "yes" && $user->hasRight('adherent', 'creer')) { $tmpmember = new Adherent($db); $adht = new AdherentType($db); + $label = GETPOST("label"); $nbcreated = 0; $now = dol_now(); $amount = price2num(GETPOST('amount', 'alpha')); @@ -363,7 +364,7 @@ if (empty($reshook)) { foreach ($toselect as $id) { $res = $tmpmember->fetch($id); if ($res > 0) { - $result = $tmpmember->subscription($now, (float) $amount); + $result = $tmpmember->subscription($now, (float) $amount, 0, '', $label); if ($result < 0) { $error++; } else { @@ -830,6 +831,7 @@ if ($massaction == 'createsubscription') { $formquestion = array( array('label' => $langs->trans("DateSubscription"), 'type' => 'other', 'value' => $date), array('label' => $langs->trans("Amount"), 'type' => 'text', 'value' => price($amount, 0, '', 0), 'name' => 'amount'), + array('label' => $langs->trans("Label"), 'type' => 'text', 'value' => '', 'name' => 'label'), array('type' => 'separator'), array('label' => $langs->trans("MoreActions"), 'type' => 'other', 'value' => $langs->trans("None").' '.img_warning($langs->trans("WarningNoComplementaryActionDone"))), ); diff --git a/htdocs/adherents/subscription.php b/htdocs/adherents/subscription.php index b56879b06b8..10ff978ec1f 100644 --- a/htdocs/adherents/subscription.php +++ b/htdocs/adherents/subscription.php @@ -717,7 +717,7 @@ if ($action != 'createsubscription' && $action != 'create_thirdparty') { $sql = "SELECT d.rowid, d.firstname, d.lastname, d.societe, d.fk_adherent_type as type,"; $sql .= " c.rowid as crowid, c.subscription,"; $sql .= " c.datec, c.fk_type as cfk_type,"; - $sql .= " c.dateadh as dateh,"; + $sql .= " c.dateadh as dateh, c.note as clabel,"; $sql .= " c.datef,"; $sql .= " c.fk_bank,"; $sql .= " b.rowid as bid,"; @@ -744,6 +744,7 @@ if ($action != 'createsubscription' && $action != 'create_thirdparty') { print_liste_field_titre('DateStart', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder, 'center '); print_liste_field_titre('DateEnd', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder, 'center '); print_liste_field_titre('Amount', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder, 'right '); + print_liste_field_titre('Label', $_SERVER["PHP_SELF"], 'clabel', '', $param, '', $sortfield, $sortorder); if (isModEnabled('bank')) { print_liste_field_titre('Account', $_SERVER["PHP_SELF"], '', '', $param, '', $sortfield, $sortorder, 'right '); } @@ -762,6 +763,7 @@ if ($action != 'createsubscription' && $action != 'create_thirdparty') { $subscriptionstatic->ref = $objp->crowid; $subscriptionstatic->id = $objp->crowid; + $subscriptionstatic->note_public = $objp->clabel; $typeid = $objp->cfk_type; if ($typeid > 0) { @@ -779,6 +781,7 @@ if ($action != 'createsubscription' && $action != 'create_thirdparty') { print ''.dol_print_date($db->jdate($objp->dateh), 'day')."\n"; print ''.dol_print_date($db->jdate($objp->datef), 'day')."\n"; print ''.price($objp->subscription).''; + print ''.$objp->clabel.''; if (isModEnabled('bank')) { print ''; if ($objp->bid) { diff --git a/htdocs/ai/assistant/parse_intent.php b/htdocs/ai/assistant/parse_intent.php index 1cfc8c9b762..cfb04707ad7 100644 --- a/htdocs/ai/assistant/parse_intent.php +++ b/htdocs/ai/assistant/parse_intent.php @@ -302,8 +302,34 @@ try { $defUrl = $servicesList[$serviceKey]['url'] ?? ''; $url = getDolGlobalString('AI_API_' . strtoupper($serviceKey) . '_URL') ?: $defUrl; - $defModel = $servicesList[$serviceKey]['textgeneration'] ?? 'gpt-4o-mini'; - $model = getDolGlobalString('AI_API_' . strtoupper($serviceKey) . '_MODEL') ?: $defModel; + // The model defaults declared in getListOfAIServices() are nested: + // $servicesList[$key]['textgeneration'] = ['default' => 'model-name'] + // Reading 'textgeneration' without ['default'] returns the inner array, which + // then fails the (string) type-hint of UniversalLLMAdapter's 4th argument with: + // "Argument #4 ($model) must be of type string, array given" + // + // The admin UI (htdocs/ai/admin/setup.php "Prompt and custom AI models" tab) also + // stores the per-function model under AI_API__MODEL_TEXT (matching the + // convention already used by Ai::generateContent() for the same data). The + // previous lookup used AI_API__MODEL which is never written by that + // form, so the user-configured model was silently ignored. + $rawDefault = $servicesList[$serviceKey]['textgeneration'] ?? null; + if (is_array($rawDefault)) { + $defModel = $rawDefault['default'] ?? 'gpt-4o-mini'; + } else { + $defModel = $rawDefault ?: 'gpt-4o-mini'; + } + $prefix = 'AI_API_' . strtoupper($serviceKey); + $model = getDolGlobalString($prefix . '_MODEL_TEXT') + ?: getDolGlobalString($prefix . '_MODEL') + ?: $defModel; + // Defensive: coerce to string if anyone stored an array in this constant + if (is_array($model)) { + $model = $model['default'] ?? $defModel; + } + if (!is_string($model) || $model === '') { + $model = (string) $defModel; + } $adapterType = $servicesList[$serviceKey]['adapter_type'] ?? 'openai'; if (!empty($apiKey)) { diff --git a/htdocs/ai/class/ai.class.php b/htdocs/ai/class/ai.class.php index 3bd792c5e5e..a5396347ec8 100644 --- a/htdocs/ai/class/ai.class.php +++ b/htdocs/ai/class/ai.class.php @@ -140,7 +140,14 @@ class Ai $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'threads'; } else { // if $function == 'docparsing', 'text...', ... $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']); - $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'chat/completions'; + if ($this->apiService == 'google') { + // Google Gemini native API: the /models/:generateContent suffix is + // appended later (once $model has been resolved). The OpenAI-style + // /chat/completions does not exist on the native Gemini endpoint. + $this->apiEndpoint = rtrim($this->apiEndpoint, '/'); + } else { + $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'chat/completions'; + } } } if ($moreendpoint) { @@ -170,6 +177,12 @@ class Ai } } + // Google Gemini: append /models/:generateContent now that $model is resolved. + if ($this->apiService == 'google' && !in_array($function, array('file', 'assistant', 'thread')) + && strpos($this->apiEndpoint, ':generateContent') === false) { + $this->apiEndpoint .= '/models/'.rawurlencode($model).':generateContent'; + } + dol_syslog("Call API for apiKey=".substr($this->apiKey, 0, 5).'***********, apiEndpoint='.$this->apiEndpoint.", model=".$model.", format=".$format); if (getDolGlobalString("AI_DEBUG")) { if (@is_writable($dolibarr_main_data_root)) { // Avoid fatal error on fopen with open_basedir @@ -269,18 +282,33 @@ class Ai "top_p": 0.95 }*/ - $arrayforpayload = array( - 'messages' => array(array('role' => 'user', 'content' => $fullInstructions)), - 'model' => $model, - ); - // Add a system message $addDateTimeContext = false; if ($addDateTimeContext) { // @phpstan-ignore-line $prePrompt = ($prePrompt ? $prePrompt.(preg_match('/[\.\!\?]$/', $prePrompt) ? '' : '.').' ' : '').'Today we are '.dol_print_date(dol_now(), 'dayhourtext'); } - if ($prePrompt) { - $arrayforpayload['messages'][] = array('role' => 'system', 'content' => $prePrompt); + + if ($this->apiService == 'google') { + // Google Gemini native payload format (different from OpenAI's "messages"). + $arrayforpayload = array( + 'contents' => array( + array('role' => 'user', 'parts' => array(array('text' => $fullInstructions))) + ) + ); + if ($prePrompt) { + $arrayforpayload['system_instruction'] = array( + 'parts' => array(array('text' => $prePrompt)) + ); + } + } else { + // OpenAI-compatible payload format (chatgpt, mistral, groq, anthropic-compat, custom, ...) + $arrayforpayload = array( + 'messages' => array(array('role' => 'user', 'content' => $fullInstructions)), + 'model' => $model, + ); + if ($prePrompt) { + $arrayforpayload['messages'][] = array('role' => 'system', 'content' => $prePrompt); + } } } @@ -296,9 +324,16 @@ class Ai $payload = json_encode($arrayforpayload); } - $headers = array( - 'Authorization: Bearer ' . $this->apiKey, - ); + if ($this->apiService == 'google') { + // Google Gemini uses the x-goog-api-key header (Bearer is not accepted by the native API). + $headers = array( + 'x-goog-api-key: ' . $this->apiKey, + ); + } else { + $headers = array( + 'Authorization: Bearer ' . $this->apiKey, + ); + } if ($function != 'file') { $headers[] = 'Content-Type: application/json'; } @@ -388,6 +423,18 @@ class Ai } else { $generatedContent = var_export($decodedResponse['error'], true); } + } elseif ($this->apiService == 'google') { + // Google Gemini response shape: candidates[0].content.parts[*].text + // (parts is an array because Gemini can return mixed-modality output; + // we concatenate the textual parts.) + $generatedContent = ''; + if (!empty($decodedResponse['candidates'][0]['content']['parts'])) { + foreach ($decodedResponse['candidates'][0]['content']['parts'] as $part) { + if (isset($part['text'])) { + $generatedContent .= $part['text']; + } + } + } } else { $generatedContent = $decodedResponse['choices'][0]['message']['content']; } diff --git a/htdocs/ai/class/llmadapter.class.php b/htdocs/ai/class/llmadapter.class.php index 4138e585d7d..b5bb2394f36 100644 --- a/htdocs/ai/class/llmadapter.class.php +++ b/htdocs/ai/class/llmadapter.class.php @@ -202,18 +202,35 @@ class UniversalLLMAdapter global $dolibarr_ai_allow_local_endpoints; $localurl = $dolibarr_ai_allow_local_endpoints ?? 0; - $result = getURLContent($url, 'POST', json_encode($data), 1, $headers, array('http', 'https'), $localurl); + // Pass $this->timeout as the response timeout so the LLM-specific value configured + // at construction time is honored (getURLContent's $timeoutresponse is the 10th arg; + // preceding args $ssl_verifypeer=-1 and $timeoutconnect=0 keep their defaults). + $result = getURLContent($url, 'POST', json_encode($data), 1, $headers, array('http', 'https'), $localurl, -1, 0, $this->timeout); - $this->lastResponse = (string) $result['content']; + $body = (string) ($result['content'] ?? ''); + $httpCode = (int) ($result['http_code'] ?? 0); + $effectiveUrl = (string) ($result['url'] ?? $url); + // Store an enriched payload so the admin Log Viewer ("VIEW LOGS" in the AI Server + // MCP setup page) shows something actionable when something goes wrong, not just + // a bare "Invalid JSON response from API." with an empty body. + $this->lastResponse = "HTTP " . $httpCode . " from " . $effectiveUrl + . "\n--- body (" . strlen($body) . " bytes) ---\n" + . $body; if (!empty($result['curl_error_no'])) { - return "Error: " . $result['curl_error_msg']; + return "Error: cURL #" . $result['curl_error_no'] . " " . $result['curl_error_msg'] . " (url=" . $effectiveUrl . ")"; } - $json = json_decode((string) (string) $result['content'], true); + $json = json_decode($body, true); if ($json === null && json_last_error() !== JSON_ERROR_NONE) { - return "Error: Invalid JSON response from API."; + // Common real-world causes: HTTP 4xx/5xx with empty body, HTML error page + // from a proxy, gateway timeout, etc. Surface the HTTP code and a short + // body snippet so the admin can diagnose without re-running with curl. + $snippet = substr($body, 0, 500); + return "Error: Invalid JSON response from API (HTTP " . $httpCode . ", " + . strlen($body) . " bytes). Body snippet: " + . ($snippet !== '' ? $snippet : ''); } if (isset($json['error'])) { diff --git a/htdocs/ai/tools/crud_objects.class.php b/htdocs/ai/tools/crud_objects.class.php index 7c57d92a11f..61933736148 100644 --- a/htdocs/ai/tools/crud_objects.class.php +++ b/htdocs/ai/tools/crud_objects.class.php @@ -196,6 +196,12 @@ If user says 'order' without any qualifier, they mean a SALES ORDER - use this t "header" => [ "type" => "object", "description" => "Invoice header data. Must include 'socid' (Customer ID).", + "properties" => [ + "socid" => ["type" => "integer", "description" => "Customer ID (Thirdparty ID)"], + "date" => ["type" => "string", "description" => "Invoice date (YYYY-MM-DD)"], + "note_public" => ["type" => "string", "description" => "Public note"], + "note_private" => ["type" => "string", "description" => "Private note"] + ], "required" => ["socid"] ], "lines" => [ @@ -233,6 +239,13 @@ If user says 'order' without any qualifier, they mean a SALES ORDER - use this t "header" => [ "type" => "object", "description" => "Header data. Must include 'socid'.", + "properties" => [ + "socid" => ["type" => "integer", "description" => "Thirdparty ID (Customer for proposal, Supplier for supplier_*)"], + "date" => ["type" => "string", "description" => "Document date (YYYY-MM-DD)"], + "duree_validite" => ["type" => "integer", "description" => "Validity in days (proposal only)"], + "note_public" => ["type" => "string", "description" => "Public note"], + "note_private" => ["type" => "string", "description" => "Private note"] + ], "required" => ["socid"] ], "lines" => [ @@ -539,8 +552,17 @@ If user says 'order' without any qualifier, they mean a SALES ORDER - use this t if ($productIdentifier !== '') { $findResult = $this->findProduct($productIdentifier); if (is_array($findResult) && isset($findResult['error'])) { - // Ensure the return array matches the expected shape by adding 'success' => false - return array_merge(['success' => false], $findResult); + // Only abort if the caller EXPLICITLY asked for a product (via the 'product' + // argument). If they only provided a free-text 'description', we silently + // fall through with $prod = null so Dolibarr creates a free-text line item, + // which is a perfectly valid Dolibarr feature. + // Previous behaviour aborted ALL line creations whose description didn't + // match an existing product reference, which broke AI-driven creation of + // invoices/orders/proposals from one-off line descriptions. + if (isset($args['product'])) { + return array_merge(['success' => false], $findResult); + } + // fall through: $prod stays null } if (is_object($findResult)) { $prod = $findResult; @@ -599,7 +621,14 @@ If user says 'order' without any qualifier, they mean a SALES ORDER - use this t $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, $discount, 'HT', 0, 0, $prodType); } elseif ($docType === 'supplier_invoice') { /** @var FactureFournisseur $object */ - $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, $discount, 0, 0, '', 0, 'HT', 0, $prodType); + // IMPORTANT: FactureFournisseur::addline() does NOT share the same signature + // as Facture::addline(). Its parameter order is: + // ($desc, $pu, $txtva, $txlocaltax1, $txlocaltax2, $qty, $fk_product, $remise_percent, ...) + // i.e. $qty is in position 6, not 3 (unlike customer Facture / Commande / Propal). + // The previous call passed our $qty as $txtva (-> a 1% VAT rate) and our $vat + // as $txlocaltax1, and position 6 ended up being a hardcoded 0 -> a line was + // inserted with qty=0, which Dolibarr silently dropped from the visible totals. + $res = $object->addline($desc, $price, $vat, 0, 0, $qty, $fkProduct, $discount, '', '', 0, 0, 'HT', $prodType); } elseif ($docType === 'supplier_order') { /** @var CommandeFournisseur $object */ $res = $object->addline($desc, $price, $qty, $vat, 0, 0, $fkProduct, $discount, 0, 0, 'HT', 0, '', '', $prodType);