feat & fix : intégration IA éditeur + onglet admin IA + corrections CSP (v1.6.24-25)
- #96 : boutons IA sidebar éditeur (analyse critique / réécriture) via Anthropic API - #97 : onglet admin /admin/ia — provider anthropic/claude_code, modèle, procédure CLI - #95 : extraction scripts inline vers fichiers JS (comments.js, post_confirm.js, admin.js) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -55,3 +55,11 @@ DATA_PATH=/srv/data/folio
|
|||||||
# Logs Apache (onglet Recherches dans /admin)
|
# Logs Apache (onglet Recherches dans /admin)
|
||||||
# Nom du fichier de log d'accès du vhost dans /var/log/apache2/
|
# Nom du fichier de log d'accès du vhost dans /var/log/apache2/
|
||||||
APACHE_ACCESS_LOG=lan.acegrp.varlog-access.log
|
APACHE_ACCESS_LOG=lan.acegrp.varlog-access.log
|
||||||
|
|
||||||
|
# IA — analyse critique et réécriture d'articles dans l'éditeur
|
||||||
|
# Provider : anthropic (API) ou claude_code (CLI local)
|
||||||
|
# AI_PROVIDER=anthropic
|
||||||
|
# Clé API Anthropic (obtenir sur https://console.anthropic.com/)
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
# Modèle à utiliser (défaut : claude-haiku-4-5-20251001) — ignoré si provider=claude_code
|
||||||
|
# AI_MODEL=claude-haiku-4-5-20251001
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.6.25] - 2026-05-16
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Admin : onglet « IA » — statut provider/clé, sélecteur `anthropic`/`claude_code`, champ modèle, procédure d'installation CLI, sauvegarde dans `site_settings.json` (#97)
|
||||||
|
- `AiService` : support du provider Claude Code CLI via `proc_open` + lecture provider/modèle depuis `SiteSettings` (#97)
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Sécurité CSP : extraction du `<script>` inline de `comments_section.php` vers `comments.js` (#95)
|
||||||
|
- Sécurité CSP : remplacement du `onclick` inline dans `wizard/step6.php` par `data-confirm-discard` + listener dans `admin.js` (#95)
|
||||||
|
- Sécurité CSP : remplacement du `oninput` inline dans `post_confirm.php` par un `addEventListener` dans `post_confirm.js` (#95)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.6.24] - 2026-05-16
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Éditeur : deux boutons IA dans la sidebar (mode édition) — « Analyse critique » et « Réécrire l'article » — appel à l'API Anthropic via `ANTHROPIC_API_KEY` dans `.env` (#96)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.6.23] - 2026-05-16
|
## [1.6.23] - 2026-05-16
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Boutons data-confirm-discard (évite onclick inline bloqué par CSP)
|
||||||
|
document.querySelectorAll('[data-confirm-discard]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var msg = btn.getAttribute('data-confirm-msg') || 'Confirmer ?';
|
||||||
|
if (window.confirm(msg)) {
|
||||||
|
window.location = btn.getAttribute('data-discard-url');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Sélection globale articles
|
// Sélection globale articles
|
||||||
var checkAll = document.getElementById('check-all');
|
var checkAll = document.getElementById('check-all');
|
||||||
if (checkAll) {
|
if (checkAll) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// ai-editor.js — boutons IA dans la sidebar éditeur
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var btnCritique = document.getElementById('btn-ai-critique');
|
||||||
|
var btnRewrite = document.getElementById('btn-ai-rewrite');
|
||||||
|
if (!btnCritique || !btnRewrite) return;
|
||||||
|
|
||||||
|
var panel = document.getElementById('ai-result-panel');
|
||||||
|
var labelEl = document.getElementById('ai-result-label');
|
||||||
|
var resultEl = document.getElementById('ai-result-content');
|
||||||
|
var btnApply = document.getElementById('btn-ai-apply');
|
||||||
|
var btnClose = document.getElementById('btn-ai-close');
|
||||||
|
var ta = document.getElementById('content');
|
||||||
|
var titleEl = document.getElementById('title');
|
||||||
|
|
||||||
|
var lastRewrite = '';
|
||||||
|
|
||||||
|
function setLoading(btn, loading) {
|
||||||
|
btn.disabled = loading;
|
||||||
|
if (loading) {
|
||||||
|
btn._origText = btn.textContent;
|
||||||
|
btn.textContent = 'En cours…';
|
||||||
|
} else {
|
||||||
|
btn.textContent = btn._origText || btn.textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callAi(action) {
|
||||||
|
var btn = (action === 'critique') ? btnCritique : btnRewrite;
|
||||||
|
|
||||||
|
setLoading(btn, true);
|
||||||
|
panel.style.display = 'none';
|
||||||
|
btnApply.style.display = 'none';
|
||||||
|
lastRewrite = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch('/?action=ai_query', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
action: action,
|
||||||
|
title: titleEl ? titleEl.value : '',
|
||||||
|
content: ta ? ta.value : '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
var data = await res.json();
|
||||||
|
|
||||||
|
if (!data.ok) {
|
||||||
|
labelEl.textContent = 'Erreur';
|
||||||
|
resultEl.textContent = data.error || 'Erreur inconnue.';
|
||||||
|
} else {
|
||||||
|
labelEl.textContent = (action === 'critique') ? 'Analyse critique' : 'Réécriture';
|
||||||
|
resultEl.textContent = data.text;
|
||||||
|
if (action === 'rewrite') {
|
||||||
|
lastRewrite = data.text;
|
||||||
|
btnApply.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panel.style.display = '';
|
||||||
|
} catch (e) {
|
||||||
|
labelEl.textContent = 'Erreur';
|
||||||
|
resultEl.textContent = 'Erreur de connexion.';
|
||||||
|
panel.style.display = '';
|
||||||
|
} finally {
|
||||||
|
setLoading(btn, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnCritique.addEventListener('click', function () { callAi('critique'); });
|
||||||
|
btnRewrite.addEventListener('click', function () { callAi('rewrite'); });
|
||||||
|
|
||||||
|
btnApply.addEventListener('click', function () {
|
||||||
|
if (!lastRewrite) return;
|
||||||
|
if (!confirm("Remplacer le contenu de l’éditeur par la réécriture IA ?")) return;
|
||||||
|
if (ta) {
|
||||||
|
ta.value = lastRewrite;
|
||||||
|
ta.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
panel.style.display = 'none';
|
||||||
|
btnApply.style.display = 'none';
|
||||||
|
lastRewrite = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
btnClose.addEventListener('click', function () {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
btnApply.style.display = 'none';
|
||||||
|
lastRewrite = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var maxAge = 365 * 24 * 3600;
|
||||||
|
function getCookie(name) {
|
||||||
|
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
|
||||||
|
return m ? decodeURIComponent(m[1]) : '';
|
||||||
|
}
|
||||||
|
function setCookie(name, value) {
|
||||||
|
document.cookie = name + '=' + encodeURIComponent(value) + ';max-age=' + maxAge + ';path=/;SameSite=Lax';
|
||||||
|
}
|
||||||
|
var nameEl = document.getElementById('comment-name');
|
||||||
|
var emailEl = document.getElementById('comment-email');
|
||||||
|
if (!nameEl || !emailEl) { return; }
|
||||||
|
var savedName = getCookie('cmt_name');
|
||||||
|
var savedEmail = getCookie('cmt_email');
|
||||||
|
if (savedName) { nameEl.value = savedName; }
|
||||||
|
if (savedEmail) { emailEl.value = savedEmail; }
|
||||||
|
var form = document.getElementById('comment-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
|
if (nameEl.value.trim()) { setCookie('cmt_name', nameEl.value.trim()); }
|
||||||
|
if (emailEl.value.trim()) { setCookie('cmt_email', emailEl.value.trim()); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -40,6 +40,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
var slugInput = document.getElementById('confirm-slug');
|
var slugInput = document.getElementById('confirm-slug');
|
||||||
var slugDisplay = document.getElementById('slug-display');
|
var slugDisplay = document.getElementById('slug-display');
|
||||||
|
|
||||||
|
if (slugInput && slugDisplay) {
|
||||||
|
slugInput.addEventListener('input', function () {
|
||||||
|
slugDisplay.textContent = slugInput.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var btnSuggest = document.getElementById('slug-btn-suggest');
|
var btnSuggest = document.getElementById('slug-btn-suggest');
|
||||||
if (btnSuggest && slugInput && slugDisplay) {
|
if (btnSuggest && slugInput && slugDisplay) {
|
||||||
btnSuggest.addEventListener('click', function () {
|
btnSuggest.addEventListener('click', function () {
|
||||||
|
|||||||
+47
-1
@@ -45,7 +45,7 @@ $action = $_GET['action'] ?? 'list';
|
|||||||
$uuid = $_GET['uuid'] ?? '';
|
$uuid = $_GET['uuid'] ?? '';
|
||||||
$slug = $_GET['slug'] ?? '';
|
$slug = $_GET['slug'] ?? '';
|
||||||
|
|
||||||
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups', 'admin_save_folio_config', 'run_engine_update', 'run_content_migrations', 'admin_delete_feed', 'rate'];
|
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags', 'book_save', 'book_delete', 'admin_save_as_groups', 'admin_save_folio_config', 'run_engine_update', 'run_content_migrations', 'admin_delete_feed', 'rate', 'admin_save_ai_config'];
|
||||||
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
|
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
|
||||||
unset($_noindexActions);
|
unset($_noindexActions);
|
||||||
|
|
||||||
@@ -1688,6 +1688,24 @@ switch ($action) {
|
|||||||
echo json_encode(fetchUrlMeta(trim($_GET['url'] ?? '')));
|
echo json_encode(fetchUrlMeta(trim($_GET['url'] ?? '')));
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
case 'ai_query':
|
||||||
|
requireAuth();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Méthode invalide']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$_aiAction = trim($_POST['action'] ?? '');
|
||||||
|
$_aiTitle = trim($_POST['title'] ?? '');
|
||||||
|
$_aiContent = str_replace("\r\n", "\n", trim($_POST['content'] ?? ''));
|
||||||
|
if (!in_array($_aiAction, ['critique', 'rewrite'], true) || $_aiContent === '') {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
require_once BASE_PATH . '/src/Service/AiService.php';
|
||||||
|
echo json_encode((new AiService())->query($_aiAction, $_aiTitle, $_aiContent));
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'import_image_step2':
|
case 'import_image_step2':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
@@ -2765,9 +2783,37 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($tab === 'ia') {
|
||||||
|
if (!isAdmin()) { http_response_code(403); exit; }
|
||||||
|
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||||
|
require_once BASE_PATH . '/src/Service/AiService.php';
|
||||||
|
$adminData['ai_provider'] = aiProvider();
|
||||||
|
$adminData['ai_model'] = aiModel();
|
||||||
|
$adminData['anthropic_key_set'] = (($_ENV['ANTHROPIC_API_KEY'] ?? getenv('ANTHROPIC_API_KEY') ?: '') !== '');
|
||||||
|
$adminData['claude_cli_found'] = is_executable('/usr/local/bin/claude');
|
||||||
|
$adminData['ai_notice'] = $_GET['notice'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
include BASE_PATH . '/templates/admin.php';
|
include BASE_PATH . '/templates/admin.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'admin_save_ai_config':
|
||||||
|
requireAuth();
|
||||||
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403); exit;
|
||||||
|
}
|
||||||
|
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||||
|
$allowedProviders = ['anthropic', 'claude_code'];
|
||||||
|
$aiProvider = in_array($_POST['ai_provider'] ?? '', $allowedProviders, true)
|
||||||
|
? $_POST['ai_provider']
|
||||||
|
: 'anthropic';
|
||||||
|
$ok = saveSiteSettings([
|
||||||
|
'ai_provider' => $aiProvider,
|
||||||
|
'ai_model' => trim($_POST['ai_model'] ?? ''),
|
||||||
|
]);
|
||||||
|
header('Location: /admin/ia?notice=' . ($ok ? 'saved' : 'error'));
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'admin_smtp_save':
|
case 'admin_smtp_save':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if (!isAdmin()) {
|
if (!isAdmin()) {
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.6.23
|
1.6.25
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class AiService
|
||||||
|
{
|
||||||
|
private const SYSTEM_CRITIQUE = <<<'PROMPT'
|
||||||
|
Tu es un relecteur expert de blogs. Analyse l'article ci-dessous et identifie ses faiblesses : arguments insuffisamment étayés, imprécisions, manques de clarté, structure à améliorer, points à développer. Sois constructif et précis. Réponds en markdown avec des sections claires.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
private const SYSTEM_REWRITE = <<<'PROMPT'
|
||||||
|
Tu es un rédacteur expert. Réécris l'article ci-dessous en améliorant le style, la clarté et la structure, sans modifier le sens ni les faits. Conserve le format markdown, les liens et les références aux images. Réponds uniquement avec l'article réécrit, sans commentaire ni explication.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
private string $apiKey;
|
||||||
|
private string $model;
|
||||||
|
private string $provider;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||||
|
$this->provider = aiProvider();
|
||||||
|
$this->model = aiModel();
|
||||||
|
$this->apiKey = $_ENV['ANTHROPIC_API_KEY'] ?? getenv('ANTHROPIC_API_KEY') ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConfigured(): bool
|
||||||
|
{
|
||||||
|
if ($this->provider === 'claude_code') {
|
||||||
|
return is_executable('/usr/local/bin/claude');
|
||||||
|
}
|
||||||
|
return $this->apiKey !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok: bool, text?: string, error?: string} */
|
||||||
|
public function query(string $action, string $title, string $content): array
|
||||||
|
{
|
||||||
|
$content = mb_substr(trim($content), 0, 8000);
|
||||||
|
if ($content === '') {
|
||||||
|
return ['ok' => false, 'error' => "Contenu de l'article vide"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$systemPrompt = match ($action) {
|
||||||
|
'critique' => self::SYSTEM_CRITIQUE,
|
||||||
|
'rewrite' => self::SYSTEM_REWRITE,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($systemPrompt === null) {
|
||||||
|
return ['ok' => false, 'error' => 'Action inconnue'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$userMsg = $title !== '' ? "# {$title}\n\n{$content}" : $content;
|
||||||
|
|
||||||
|
if ($this->provider === 'claude_code') {
|
||||||
|
return $this->queryClaudeCode($systemPrompt, $userMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->queryAnthropic($action, $systemPrompt, $userMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok: bool, text?: string, error?: string} */
|
||||||
|
private function queryAnthropic(string $action, string $systemPrompt, string $userMsg): array
|
||||||
|
{
|
||||||
|
if ($this->apiKey === '') {
|
||||||
|
return ['ok' => false, 'error' => 'Clé Anthropic non configurée (ANTHROPIC_API_KEY manquante dans .env)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxTokens = ($action === 'rewrite') ? 4096 : 1200;
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'model' => $this->model,
|
||||||
|
'max_tokens' => $maxTokens,
|
||||||
|
'system' => $systemPrompt,
|
||||||
|
'messages' => [['role' => 'user', 'content' => $userMsg]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init('https://api.anthropic.com/v1/messages');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'x-api-key: ' . $this->apiKey,
|
||||||
|
'anthropic-version: 2023-06-01',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($err !== '') {
|
||||||
|
return ['ok' => false, 'error' => 'Erreur réseau : ' . $err];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode((string) $resp, true);
|
||||||
|
|
||||||
|
if ($http !== 200) {
|
||||||
|
$msg = $data['error']['message'] ?? ('Anthropic HTTP ' . $http);
|
||||||
|
return ['ok' => false, 'error' => $msg];
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $data['content'][0]['text'] ?? '';
|
||||||
|
return ['ok' => true, 'text' => $text];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{ok: bool, text?: string, error?: string} */
|
||||||
|
private function queryClaudeCode(string $systemPrompt, string $userMsg): array
|
||||||
|
{
|
||||||
|
$bin = '/usr/local/bin/claude';
|
||||||
|
if (!is_executable($bin)) {
|
||||||
|
return ['ok' => false, 'error' => 'Claude Code CLI introuvable (/usr/local/bin/claude)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$prompt = $systemPrompt . "\n\n" . $userMsg;
|
||||||
|
$cmd = $bin . ' --print ' . escapeshellarg($prompt) . ' 2>&1';
|
||||||
|
$env = ['HOME' => '/var/lib/claude-www', 'PATH' => '/usr/local/bin:/usr/bin:/bin'];
|
||||||
|
$desc = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
||||||
|
$proc = proc_open($cmd, $desc, $pipes, '/tmp', $env);
|
||||||
|
|
||||||
|
if (!is_resource($proc)) {
|
||||||
|
return ['ok' => false, 'error' => 'proc_open échoué'];
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($pipes[0]);
|
||||||
|
$out = stream_get_contents($pipes[1]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
$code = proc_close($proc);
|
||||||
|
|
||||||
|
if ($code !== 0) {
|
||||||
|
return ['ok' => false, 'error' => 'Claude Code exit ' . $code . ' : ' . trim((string)$out)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true, 'text' => trim((string)$out)];
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-1
@@ -93,10 +93,24 @@ function asGroups(): array
|
|||||||
return is_array($raw) ? $raw : [];
|
return is_array($raw) ? $raw : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aiProvider(): string
|
||||||
|
{
|
||||||
|
$v = siteSettings()['ai_provider'] ?? '';
|
||||||
|
if ($v !== '') return $v;
|
||||||
|
return $_ENV['AI_PROVIDER'] ?? getenv('AI_PROVIDER') ?: 'anthropic';
|
||||||
|
}
|
||||||
|
|
||||||
|
function aiModel(): string
|
||||||
|
{
|
||||||
|
$v = siteSettings()['ai_model'] ?? '';
|
||||||
|
if ($v !== '') return $v;
|
||||||
|
return $_ENV['AI_MODEL'] ?? getenv('AI_MODEL') ?: 'claude-haiku-4-5-20251001';
|
||||||
|
}
|
||||||
|
|
||||||
function saveSiteSettings(array $data): bool
|
function saveSiteSettings(array $data): bool
|
||||||
{
|
{
|
||||||
$current = siteSettings();
|
$current = siteSettings();
|
||||||
$stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url', 'apache_access_log', 'folio_repo_url', 'folio_update_branch'];
|
$stringKeys = ['site_title', 'site_claim', 'site_lang', 'site_license_label', 'site_license_url', 'apache_access_log', 'folio_repo_url', 'folio_update_branch', 'ai_provider', 'ai_model'];
|
||||||
foreach ($stringKeys as $key) {
|
foreach ($stringKeys as $key) {
|
||||||
if (array_key_exists($key, $data)) {
|
if (array_key_exists($key, $data)) {
|
||||||
$val = trim((string)$data[$key]);
|
$val = trim((string)$data[$key]);
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
<a class="nav-link <?= $tab === 'flux' ? 'active' : '' ?>"
|
<a class="nav-link <?= $tab === 'flux' ? 'active' : '' ?>"
|
||||||
href="/admin/flux">Flux</a>
|
href="/admin/flux">Flux</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link <?= $tab === 'ia' ? 'active' : '' ?>"
|
||||||
|
href="/admin/ia">IA</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link <?= $tab === 'stats' ? 'active' : '' ?>"
|
<a class="nav-link <?= $tab === 'stats' ? 'active' : '' ?>"
|
||||||
href="/admin/stats">Statistiques</a>
|
href="/admin/stats">Statistiques</a>
|
||||||
@@ -1486,6 +1490,127 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($tab === 'ia' && isAdmin()): ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$_aiNotice = $adminData['ai_notice'] ?? '';
|
||||||
|
$_aiProvider = $adminData['ai_provider'] ?? 'anthropic';
|
||||||
|
$_aiModel = $adminData['ai_model'] ?? '';
|
||||||
|
$_anthropicOk = $adminData['anthropic_key_set'] ?? false;
|
||||||
|
$_cliOk = $adminData['claude_cli_found'] ?? false;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if ($_aiNotice === 'saved'): ?>
|
||||||
|
<div class="alert alert-success py-2 small">Configuration IA enregistrée.</div>
|
||||||
|
<?php elseif ($_aiNotice === 'error'): ?>
|
||||||
|
<div class="alert alert-danger py-2 small">Erreur lors de l'enregistrement.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h5 class="mb-3">Intelligence artificielle</h5>
|
||||||
|
|
||||||
|
<!-- Section 1 — Statut -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-semibold small">Statut</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3" scope="row">Clé Anthropic (<code>ANTHROPIC_API_KEY</code>)</th>
|
||||||
|
<td><?= $_anthropicOk ? '<span class="text-success">✓ Configurée</span>' : '<span class="text-danger">✗ Absente</span>' ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3" scope="row">Claude Code CLI (<code>/usr/local/bin/claude</code>)</th>
|
||||||
|
<td><?= $_cliOk ? '<span class="text-success">✓ Trouvé</span>' : '<span class="text-danger">✗ Introuvable</span>' ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3" scope="row">Provider actif</th>
|
||||||
|
<td><code><?= htmlspecialchars($_aiProvider) ?></code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3" scope="row">Modèle actif</th>
|
||||||
|
<td><code><?= htmlspecialchars($_aiModel ?: 'claude-haiku-4-5-20251001 (défaut)') ?></code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 2 — Configuration -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-semibold small">Configuration</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/?action=admin_save_ai_config">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold small">Provider</label>
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="ai_provider" id="ai_provider_anthropic"
|
||||||
|
value="anthropic" <?= $_aiProvider === 'anthropic' ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="ai_provider_anthropic">Anthropic (API)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="ai_provider" id="ai_provider_claude_code"
|
||||||
|
value="claude_code" <?= $_aiProvider === 'claude_code' ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="ai_provider_claude_code">Claude Code CLI</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="ai_model" class="form-label fw-semibold small">Modèle Anthropic</label>
|
||||||
|
<input type="text" class="form-control form-control-sm font-monospace" id="ai_model" name="ai_model"
|
||||||
|
value="<?= htmlspecialchars($_aiModel) ?>"
|
||||||
|
placeholder="claude-haiku-4-5-20251001">
|
||||||
|
<div class="form-text">Laisser vide pour utiliser le défaut (<code>claude-haiku-4-5-20251001</code>). Ignoré si le provider est Claude Code CLI.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 3 — Clé Anthropic -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-semibold small">Clé API Anthropic</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning py-2 small mb-0">
|
||||||
|
<strong>La clé API Anthropic ne peut pas être saisie ici.</strong><br>
|
||||||
|
Elle doit être définie dans le fichier <code>.env</code> du serveur :
|
||||||
|
<pre class="mt-2 mb-0 small"><code>ANTHROPIC_API_KEY=sk-ant-...</code></pre>
|
||||||
|
<div class="mt-2">Statut actuel : <?= $_anthropicOk ? '<span class="text-success">✓ Configurée</span>' : '<span class="text-danger">✗ Absente</span>' ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 4 — Procédure Claude Code CLI -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header fw-semibold small">Procédure d'installation de Claude Code CLI</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if ($_cliOk): ?>
|
||||||
|
<div class="alert alert-success py-2 small mb-3">✓ <code>/usr/local/bin/claude</code> détecté.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="alert alert-secondary py-2 small mb-3">✗ <code>/usr/local/bin/claude</code> introuvable — suivez les étapes ci-dessous.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="small text-muted">À exécuter en SSH sur le serveur (en root ou via sudo) :</p>
|
||||||
|
<pre class="bg-dark text-light p-3 rounded small"><code># 1. Installer Claude Code CLI (en root)
|
||||||
|
sudo npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
|
# Vérifier l'installation
|
||||||
|
/usr/local/bin/claude --version
|
||||||
|
|
||||||
|
# 2. Créer le répertoire HOME de www-data pour Claude
|
||||||
|
sudo mkdir -p /var/lib/claude-www
|
||||||
|
sudo chown www-data:www-data /var/lib/claude-www
|
||||||
|
|
||||||
|
# 3. Authentifier Claude en tant que www-data
|
||||||
|
sudo -u www-data HOME=/var/lib/claude-www /usr/local/bin/claude auth login
|
||||||
|
# → Suivre les instructions (OAuth navigateur ou clé API)
|
||||||
|
|
||||||
|
# 4. Vérifier que ça fonctionne
|
||||||
|
sudo -u www-data HOME=/var/lib/claude-www /usr/local/bin/claude --print "Réponds juste OK"</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($tab === 'stats' && isAdmin()): ?>
|
<?php if ($tab === 'stats' && isAdmin()): ?>
|
||||||
|
|
||||||
<?php include __DIR__ . '/admin_stats.php'; ?>
|
<?php include __DIR__ . '/admin_stats.php'; ?>
|
||||||
|
|||||||
@@ -142,29 +142,4 @@ setcookie('_csrf_c', $_csrfToken, [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script src="/assets/js/comments.js" defer></script>
|
||||||
(function () {
|
|
||||||
var maxAge = 365 * 24 * 3600;
|
|
||||||
function getCookie(name) {
|
|
||||||
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
|
|
||||||
return m ? decodeURIComponent(m[1]) : '';
|
|
||||||
}
|
|
||||||
function setCookie(name, value) {
|
|
||||||
document.cookie = name + '=' + encodeURIComponent(value) + ';max-age=' + maxAge + ';path=/;SameSite=Lax';
|
|
||||||
}
|
|
||||||
var nameEl = document.getElementById('comment-name');
|
|
||||||
var emailEl = document.getElementById('comment-email');
|
|
||||||
if (!nameEl || !emailEl) { return; }
|
|
||||||
var savedName = getCookie('cmt_name');
|
|
||||||
var savedEmail = getCookie('cmt_email');
|
|
||||||
if (savedName) { nameEl.value = savedName; }
|
|
||||||
if (savedEmail) { emailEl.value = savedEmail; }
|
|
||||||
var form = document.getElementById('comment-form');
|
|
||||||
if (form) {
|
|
||||||
form.addEventListener('submit', function () {
|
|
||||||
if (nameEl.value.trim()) { setCookie('cmt_name', nameEl.value.trim()); }
|
|
||||||
if (emailEl.value.trim()) { setCookie('cmt_email', emailEl.value.trim()); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ $_layoutCurrentCat = trim($_GET['cat'] ?? '');
|
|||||||
<?php if (!empty($shareBar ?? false)): ?>
|
<?php if (!empty($shareBar ?? false)): ?>
|
||||||
<script src="<?= _av($_pub, 'js/share.js') ?>" defer></script>
|
<script src="<?= _av($_pub, 'js/share.js') ?>" defer></script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($aiEditor ?? false)): ?>
|
||||||
|
<script src="<?= _av($_pub, 'js/ai-editor.js') ?>"></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -115,8 +115,7 @@ $slugOriginal = $postSlug;
|
|||||||
</label>
|
</label>
|
||||||
<input type="text" class="form-control form-control-sm font-monospace" id="confirm-slug" name="slug"
|
<input type="text" class="form-control form-control-sm font-monospace" id="confirm-slug" name="slug"
|
||||||
value="<?= htmlspecialchars($slugDefault) ?>"
|
value="<?= htmlspecialchars($slugDefault) ?>"
|
||||||
pattern="[a-z0-9][a-z0-9\-]*"
|
pattern="[a-z0-9][a-z0-9\-]*">
|
||||||
oninput="document.getElementById('slug-display').textContent=this.value">
|
|
||||||
<?php if ($titleChanged && $autoSlug !== $slugOriginal): ?>
|
<?php if ($titleChanged && $autoSlug !== $slugOriginal): ?>
|
||||||
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
|
<div class="mt-2 d-flex align-items-center gap-2 flex-wrap">
|
||||||
<small class="text-muted">Slug recalculé depuis le nouveau titre. Slug initial :</small>
|
<small class="text-muted">Slug recalculé depuis le nouveau titre. Slug initial :</small>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ $dateValue = isset($published_at)
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if ($action === 'edit'): ?>
|
<?php if ($action === 'edit'): ?>
|
||||||
|
<?php $aiEditor = true; ?>
|
||||||
<div id="vl-page"
|
<div id="vl-page"
|
||||||
data-uuid="<?= htmlspecialchars($uuid) ?>"
|
data-uuid="<?= htmlspecialchars($uuid) ?>"
|
||||||
data-insert-url="<?= htmlspecialchars($insertUrl ?? '') ?>"
|
data-insert-url="<?= htmlspecialchars($insertUrl ?? '') ?>"
|
||||||
@@ -221,6 +222,38 @@ $dateValue = isset($published_at)
|
|||||||
|
|
||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="fw-semibold small mb-2">IA</p>
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
<button type="button" id="btn-ai-critique"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
Analyse critique
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btn-ai-rewrite"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
Réécrire l'article
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="ai-result-panel" class="mt-3" style="display:none">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||||
|
<span id="ai-result-label" class="fw-semibold small"></span>
|
||||||
|
<button type="button" id="btn-ai-close" class="btn-close btn-sm"
|
||||||
|
aria-label="Fermer"></button>
|
||||||
|
</div>
|
||||||
|
<div id="ai-result-content"
|
||||||
|
class="border rounded p-2 small"
|
||||||
|
style="max-height:400px;overflow-y:auto;white-space:pre-wrap;font-family:inherit;background:#f8f9fa">
|
||||||
|
</div>
|
||||||
|
<button type="button" id="btn-ai-apply"
|
||||||
|
class="btn btn-warning btn-sm mt-2"
|
||||||
|
style="display:none">
|
||||||
|
Appliquer dans l'éditeur
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
|
||||||
<?php if (!empty($existingFiles)): ?>
|
<?php if (!empty($existingFiles)): ?>
|
||||||
<?php $coverFile = $article['cover'] ?? ''; ?>
|
<?php $coverFile = $article['cover'] ?? ''; ?>
|
||||||
<?php $filesMeta = $article['files_meta'] ?? []; ?>
|
<?php $filesMeta = $article['files_meta'] ?? []; ?>
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ $_formAction = '/edit/' . rawurlencode($uuid) . '/6';
|
|||||||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||||
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
|
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||||
onclick="if(confirm('Abandonner les modifications et supprimer ce brouillon ?')) window.location='/edit/<?= rawurlencode($uuid) ?>/discard'">
|
data-confirm-discard
|
||||||
|
data-discard-url="/edit/<?= rawurlencode($uuid) ?>/discard"
|
||||||
|
data-confirm-msg="Abandonner les modifications et supprimer ce brouillon ?">
|
||||||
Abandonner
|
Abandonner
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-success">✓ Confirmer et enregistrer</button>
|
<button type="submit" class="btn btn-success">✓ Confirmer et enregistrer</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user