>|null Cached collections list */
private ?array $collectionsCache = null;
public function __construct()
{
$config = AIConfig::fromCredentialsFile();
$this->chatService = $config->createChatService();
$this->collectionRepository = new CollectionRepository();
$this->collectionValidator = new CollectionValidator($this->collectionRepository);
$this->db = $this->initializeDatabase();
}
/**
* GET /chat
* Show chat interface with session list, create new session if none
*/
public function index(): void
{
$sessions = $this->getSessions();
// Create new session and redirect
$uuid = $this->createSession();
header('Location: /chat/' . $uuid);
exit;
}
/**
* GET /chat/{uuid}
* Show specific chat session
*/
public function show(string $uuid): void
{
$session = $this->getSession($uuid);
if ($session === null) {
header('Location: /chat');
exit;
}
$messages = $this->getMessages($session['id']);
$sessions = $this->getSessions();
$authorProfiles = $this->getAuthorProfiles();
$systemPrompts = $this->getSystemPrompts();
$collections = $this->getAvailableCollections();
$this->view('chat.index', [
'title' => $session['title'] ?? 'KI-Chat',
'session' => $session,
'messages' => $messages,
'sessions' => $sessions,
'authorProfiles' => $authorProfiles,
'systemPrompts' => $systemPrompts,
'collections' => $collections,
'models' => ModelConfig::getAll(),
'defaultModel' => ModelConfig::DEFAULT_MODEL,
]);
}
/**
* GET /chat/sessions (HTMX partial)
* Return session list HTML
*/
public function sessionList(): void
{
$sessions = $this->getSessions();
$this->view('chat.partials.session-list', [
'sessions' => $sessions,
'currentUuid' => $_GET['current'] ?? null,
]);
}
/**
* POST /chat/{uuid}/message (HTMX)
* Process message and return HTML response
*/
public function message(string $uuid): void
{
$session = $this->getSession($uuid);
if ($session === null) {
echo '
Session nicht gefunden.
';
return;
}
$question = trim($_POST['message'] ?? '');
$model = $this->validateModel($_POST['model'] ?? $session['model']);
$sessionCollections = json_decode($session['collections'] ?? '["documents"]', true) ?: ['documents'];
$collections = $this->validateCollections($_POST['collections'] ?? $sessionCollections);
$contextLimit = $this->validateContextLimit((int) ($_POST['context_limit'] ?? $session['context_limit'] ?? 5));
$authorProfileId = $this->validateAuthorProfileId((int) ($_POST['author_profile_id'] ?? $session['author_profile_id'] ?? 0));
$systemPromptId = (int) ($_POST['system_prompt_id'] ?? $session['system_prompt_id'] ?? 1);
$temperature = $this->validateTemperature((float) ($_POST['temperature'] ?? $session['temperature'] ?? 0.7));
$maxTokens = $this->validateMaxTokens((int) ($_POST['max_tokens'] ?? $session['max_tokens'] ?? 4096));
// Update session if settings changed
$currentLimit = (int) ($session['context_limit'] ?? 5);
$currentProfileId = (int) ($session['author_profile_id'] ?? 0);
$currentTemperature = (float) ($session['temperature'] ?? 0.7);
$currentMaxTokens = (int) ($session['max_tokens'] ?? 4096);
$collectionsJson = json_encode($collections);
if ($model !== $session['model'] || $collectionsJson !== ($session['collections'] ?? '["documents"]') || $contextLimit !== $currentLimit || $authorProfileId !== $currentProfileId || $temperature !== $currentTemperature || $maxTokens !== $currentMaxTokens) {
$this->updateSessionSettings($session['id'], $model, $collections, $contextLimit, $authorProfileId, $temperature, $maxTokens);
}
if ($question === '') {
echo 'Bitte gib eine Frage ein.
';
return;
}
// Validate collection compatibility (vector dimensions)
if (!empty($collections)) {
$compatibility = $this->validateCollectionCompatibility($collections);
if (!$compatibility['valid']) {
echo '';
echo 'Collection-Fehler: ' . htmlspecialchars($compatibility['error']);
echo 'Bitte wähle nur Collections mit gleichem Embedding-Modell. ';
echo '
';
return;
}
}
try {
// Save user message
$this->saveMessage($session['id'], 'user', $question, $model);
// Auto-set title from first message
if ($session['title'] === null) {
$title = mb_substr($question, 0, 50) . (mb_strlen($question) > 50 ? '...' : '');
$this->updateSessionTitle($session['id'], $title);
}
// Get style prompt from author profile
$stylePrompt = $this->getStylePromptFromProfile($authorProfileId);
// Get system prompt from content_config
$systemPromptData = $this->getSystemPromptById($systemPromptId);
$customSystemPrompt = $this->extractPromptText($systemPromptData);
// Track timing
$startMicrotime = microtime(true);
// Get response from AI
$result = $this->askChat(
$question,
$model,
$collections,
$contextLimit,
$stylePrompt,
$customSystemPrompt,
$temperature,
$maxTokens
);
$endMicrotime = microtime(true);
if (isset($result['error'])) {
echo '' . htmlspecialchars($result['error']) . '
';
return;
}
// Save assistant message with full tracking
$tokensInput = $result['usage']['input_tokens'] ?? null;
$tokensOutput = $result['usage']['output_tokens'] ?? null;
$sources = $result['sources'] ?? [];
$this->saveMessage(
$session['id'],
'assistant',
$result['answer'],
$model,
$tokensInput,
$tokensOutput,
$sources,
$startMicrotime,
$endMicrotime,
$authorProfileId > 0 ? $authorProfileId : null,
$systemPromptId > 0 ? $systemPromptId : null,
$collectionsJson,
$contextLimit
);
$this->renderResponse($question, $result, $model);
} catch (\Exception $e) {
echo 'Fehler: ' . htmlspecialchars($e->getMessage()) . '
';
}
}
/**
* POST /chat/{uuid}/title (HTMX)
* Update session title
*/
public function updateTitle(string $uuid): void
{
$session = $this->getSession($uuid);
if ($session === null) {
$this->notFound('Session nicht gefunden');
}
$title = trim($_POST['title'] ?? '');
// Validate title
if ($title === '') {
$title = 'Neuer Chat';
}
// Max 100 characters
$title = mb_substr($title, 0, 100);
// Save to database
$this->updateSessionTitle($session['id'], $title);
// Return updated title HTML
echo htmlspecialchars($title);
}
/**
* POST /chat/{uuid}/system-prompt (HTMX)
* Update session system prompt
*/
public function systemPrompt(string $uuid): void
{
$session = $this->getSession($uuid);
if ($session === null) {
$this->notFound('Session nicht gefunden');
}
$systemPrompt = trim($_POST['system_prompt'] ?? '');
// Validate and sanitize system prompt
$systemPrompt = $this->validateSystemPrompt($systemPrompt);
// Save to database
$this->updateSystemPrompt($session['id'], $systemPrompt);
// Return success message
echo 'System-Prompt gespeichert.
';
}
/**
* GET /chat/{uuid}/system-prompt (HTMX)
* Get system prompt form
*/
public function getSystemPrompt(string $uuid): void
{
$session = $this->getSession($uuid);
if ($session === null) {
$this->notFound('Session nicht gefunden');
}
$defaultPrompt = $this->getDefaultSystemPrompt();
$currentPrompt = $session['system_prompt'] ?? '';
$this->view('chat.partials.system-prompt-modal', [
'session' => $session,
'currentPrompt' => $currentPrompt,
'defaultPrompt' => $defaultPrompt,
]);
}
/**
* DELETE /chat/{uuid}
* Delete a session
*/
public function delete(string $uuid): void
{
$session = $this->getSession($uuid);
if ($session !== null) {
$stmt = $this->db->prepare('DELETE FROM chat_sessions WHERE id = ?');
$stmt->execute([$session['id']]);
}
// Return success for HTMX
header('HX-Redirect: /chat');
echo 'OK';
}
// ========== Private Helper Methods ==========
/**
* Initialize database connection
*/
private function initializeDatabase(): \PDO
{
return \Infrastructure\Config\DatabaseFactory::content();
}
/**
* Create a new chat session
*/
private function createSession(): string
{
$uuid = $this->generateUuid();
$stmt = $this->db->prepare(
'INSERT INTO chat_sessions (uuid, model, collections, context_limit) VALUES (?, ?, ?, ?)'
);
$stmt->execute([$uuid, 'claude-opus-4-5-20251101', '["documents"]', 5]);
return $uuid;
}
/**
* Get session by UUID
*/
private function getSession(string $uuid): ?array
{
$stmt = $this->db->prepare('SELECT * FROM chat_sessions WHERE uuid = ?');
$stmt->execute([$uuid]);
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
return $result !== false ? $result : null;
}
/**
* Get all sessions ordered by most recent
*/
private function getSessions(): array
{
$stmt = $this->db->query(
'SELECT s.*,
(SELECT COUNT(*) FROM chat_messages WHERE session_id = s.id) as message_count,
(SELECT COALESCE(SUM(tokens_input), 0) FROM chat_messages WHERE session_id = s.id) as total_input_tokens,
(SELECT COALESCE(SUM(tokens_output), 0) FROM chat_messages WHERE session_id = s.id) as total_output_tokens,
(SELECT COALESCE(SUM(end_microtime - start_microtime), 0) FROM chat_messages WHERE session_id = s.id AND start_microtime IS NOT NULL) as total_duration,
(SELECT model FROM chat_messages WHERE session_id = s.id AND role = "assistant" ORDER BY id DESC LIMIT 1) as last_model
FROM chat_sessions s
ORDER BY s.last_activity DESC
LIMIT 50'
);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get author profiles from content_config table
*/
private function getAuthorProfiles(): array
{
$stmt = $this->db->query(
"SELECT id, name, slug, content as config
FROM content_config
WHERE type = 'author_profile' AND status = 'active'
ORDER BY name"
);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get author profile by ID
*/
private function getAuthorProfile(int $profileId): ?array
{
$stmt = $this->db->prepare(
"SELECT id, name, slug, content as config
FROM content_config
WHERE id = ? AND type = 'author_profile' AND status = 'active'"
);
$stmt->execute([$profileId]);
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
return $result !== false ? $result : null;
}
/**
* Get system prompts from content_config table
*/
private function getSystemPrompts(): array
{
$stmt = $this->db->query(
"SELECT id, name, slug, content
FROM content_config
WHERE type = 'system_prompt' AND status = 'active'
ORDER BY name"
);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get system prompt by ID from content_config
*/
private function getSystemPromptById(int $promptId): ?array
{
$stmt = $this->db->prepare(
"SELECT id, name, slug, content
FROM content_config
WHERE id = ? AND type = 'system_prompt' AND status = 'active'"
);
$stmt->execute([$promptId]);
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
return $result !== false ? $result : null;
}
/**
* Extract prompt text from system prompt data
*/
private function extractPromptText(?array $promptData): ?string
{
if ($promptData === null) {
return null;
}
$content = json_decode($promptData['content'], true);
if ($content === null) {
return null;
}
return $content['prompt'] ?? null;
}
/**
* Get messages for a session
*/
private function getMessages(int $sessionId): array
{
$stmt = $this->db->prepare(
'SELECT * FROM chat_messages WHERE session_id = ? ORDER BY created_at ASC'
);
$stmt->execute([$sessionId]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Save a message to the database
*/
private function saveMessage(
int $sessionId,
string $role,
string $content,
string $model,
?int $tokensInput = null,
?int $tokensOutput = null,
array $sources = [],
?float $startMicrotime = null,
?float $endMicrotime = null,
?int $authorProfileId = null,
?int $systemPromptId = null,
?string $collectionsJson = null,
?int $contextLimit = null
): void {
$stmt = $this->db->prepare(
'INSERT INTO chat_messages
(session_id, role, content, model, tokens_input, tokens_output, sources,
start_microtime, end_microtime, author_profile_id, system_prompt_id, collections, context_limit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$sessionId,
$role,
$content,
$model,
$tokensInput,
$tokensOutput,
$sources !== [] ? json_encode($sources) : null,
$startMicrotime,
$endMicrotime,
$authorProfileId,
$systemPromptId,
$collectionsJson,
$contextLimit,
]);
// Update session timestamp
$this->db->prepare('UPDATE chat_sessions SET updated_at = NOW() WHERE id = ?')
->execute([$sessionId]);
}
/**
* Update session title
*/
private function updateSessionTitle(int $sessionId, string $title): void
{
$stmt = $this->db->prepare('UPDATE chat_sessions SET title = ? WHERE id = ?');
$stmt->execute([$title, $sessionId]);
}
/**
* Generate UUID v4
*/
private function generateUuid(): string
{
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
/**
* Validate model parameter
*/
private function validateModel(string $model): string
{
return ModelConfig::validate($model);
}
/**
* Get available collections from database (cached per request)
*
* @return array> List of collection data
*/
private function getAvailableCollections(): array
{
if ($this->collectionsCache === null) {
$this->collectionsCache = $this->collectionRepository->getSearchable();
// Fallback if database is empty
if ($this->collectionsCache === []) {
$this->collectionsCache = [
['collection_id' => 'documents', 'display_name' => 'Dokumente', 'points_count' => 0, 'vector_size' => 1024],
];
}
}
return $this->collectionsCache;
}
/**
* Get collection IDs only (for backwards compatibility)
*
* @return array
*/
private function getAvailableCollectionIds(): array
{
return array_column($this->getAvailableCollections(), 'collection_id');
}
/**
* Validate collections array against available collections
*
* @param array|string $collections Collections to validate (array or single string)
* @return array Validated collections array (may be empty for no RAG)
*/
private function validateCollections(array|string $collections): array
{
$availableIds = $this->getAvailableCollectionIds();
// Handle string input (backwards compatibility)
if (is_string($collections)) {
$collections = [$collections];
}
// Filter to only valid collections
$valid = array_filter($collections, fn ($c) => in_array($c, $availableIds, true));
return array_values($valid);
}
/**
* Validate collection compatibility (vector dimensions)
*
* @param array $collectionIds
* @return array{valid: bool, error: string|null}
*/
private function validateCollectionCompatibility(array $collectionIds): array
{
if (empty($collectionIds)) {
return ['valid' => true, 'error' => null];
}
$result = $this->collectionValidator->validateSelection($collectionIds);
return [
'valid' => $result->isValid(),
'error' => $result->getError(),
];
}
/**
* Validate context limit parameter against allowed values
*/
private function validateContextLimit(int $limit): int
{
$allowedLimits = [3, 5, 10, 15];
return in_array($limit, $allowedLimits, true) ? $limit : 5;
}
/**
* Validate author profile ID (0 = no profile)
*/
private function validateAuthorProfileId(int $profileId): int
{
if ($profileId === 0) {
return 0;
}
$profile = $this->getAuthorProfile($profileId);
return $profile !== null ? $profileId : 0;
}
/**
* Validate temperature parameter (0.0 - 1.0)
*/
private function validateTemperature(float $temperature): float
{
return max(0.0, min(1.0, $temperature));
}
/**
* Validate max_tokens parameter against allowed values
*/
private function validateMaxTokens(int $maxTokens): int
{
$allowedValues = [1024, 2048, 4096, 8192];
return in_array($maxTokens, $allowedValues, true) ? $maxTokens : 4096;
}
/**
* Generate style prompt from author profile config
*/
private function getStylePromptFromProfile(int $profileId): ?string
{
if ($profileId === 0) {
return null;
}
$profile = $this->getAuthorProfile($profileId);
if ($profile === null) {
return null;
}
$config = json_decode($profile['config'], true);
if ($config === null) {
return null;
}
$parts = [];
// Build style instructions from config
if (isset($config['stimme']['ton'])) {
$parts[] = 'Ton: ' . $config['stimme']['ton'];
}
if (isset($config['stimme']['perspektive'])) {
$parts[] = 'Perspektive: ' . $config['stimme']['perspektive'];
}
if (isset($config['stil']['fachsprache']) && $config['stil']['fachsprache']) {
$parts[] = 'Verwende Fachsprache';
}
if (isset($config['stil']['beispiele']) && $config['stil']['beispiele'] === 'häufig') {
$parts[] = 'Nutze häufig Beispiele';
}
if (isset($config['stil']['listen']) && $config['stil']['listen'] === 'bevorzugt') {
$parts[] = 'Bevorzuge Listen und Bullet-Points';
}
if (isset($config['tabus']) && is_array($config['tabus'])) {
$parts[] = 'Vermeide: ' . implode(', ', $config['tabus']);
}
if ($parts === []) {
return null;
}
return 'Schreibstil (' . $profile['name'] . '): ' . implode('. ', $parts) . '.';
}
/**
* Update session model, collections, context limit, author profile, temperature and max_tokens settings
*
* @param array $collections Array of collection names
*/
private function updateSessionSettings(int $sessionId, string $model, array $collections, int $contextLimit = 5, int $authorProfileId = 0, float $temperature = 0.7, int $maxTokens = 4096): void
{
$collectionsJson = json_encode($collections);
$stmt = $this->db->prepare('UPDATE chat_sessions SET model = ?, collections = ?, context_limit = ?, author_profile_id = ?, temperature = ?, max_tokens = ? WHERE id = ?');
$stmt->execute([$model, $collectionsJson, $contextLimit, $authorProfileId > 0 ? $authorProfileId : null, $temperature, $maxTokens, $sessionId]);
}
/**
* Validate system prompt input.
*
* NOTE: Pattern-based prompt injection filtering was intentionally removed.
* Such filtering provides false security since attackers can easily rephrase
* malicious instructions. LLM prompt injection cannot be reliably prevented
* through input sanitization alone.
*
* Instead, we apply:
* - Length limits (to prevent resource abuse)
* - Logging (for monitoring and incident response)
*
* True prompt injection mitigation requires architectural measures like
* sandboxing LLM outputs and limiting LLM capabilities.
*/
private function validateSystemPrompt(string $prompt): string
{
// Length limit: prevent resource abuse
$maxLength = 2000;
$wasTruncated = mb_strlen($prompt) > $maxLength;
$prompt = mb_substr($prompt, 0, $maxLength);
// Log custom system prompts for monitoring
$this->logSystemPromptChange($prompt, $wasTruncated);
return trim($prompt);
}
/**
* Log system prompt changes for security monitoring.
*/
private function logSystemPromptChange(string $prompt, bool $wasTruncated): void
{
$logData = [
'prompt_length' => mb_strlen($prompt),
'truncated' => $wasTruncated,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'timestamp' => date('c'),
];
// Log to database via ki_dev.mcp_log
try {
$devDb = \Infrastructure\Config\DatabaseFactory::dev();
$stmt = $devDb->prepare(
"INSERT INTO mcp_log (tool, operation, parameters, result, logged_at)
VALUES ('chat', 'system_prompt_change', :params, 'logged', NOW())"
);
$stmt->execute(['params' => json_encode($logData)]);
} catch (\Exception $e) {
// Silently fail - logging should not break functionality
}
}
/**
* Update session system prompt
*/
private function updateSystemPrompt(int $sessionId, string $systemPrompt): void
{
$stmt = $this->db->prepare('UPDATE chat_sessions SET system_prompt = ? WHERE id = ?');
$stmt->execute([$systemPrompt !== '' ? $systemPrompt : null, $sessionId]);
}
/**
* Get default system prompt from ClaudeService
*/
private function getDefaultSystemPrompt(): string
{
return <<<'PROMPT'
Du bist ein hilfreicher Assistent für Fragen zu systemischem Teamcoaching und Teamentwicklung.
Beantworte die Frage des Nutzers basierend auf dem bereitgestellten Kontext.
- Antworte auf Deutsch
- Sei präzise und hilfreich
- Wenn der Kontext die Frage nicht beantwortet, sage das ehrlich
- Verweise auf die Quellen wenn passend
PROMPT;
}
/**
* Ask chat question using ChatService
*
* @param array $collections Array of collection names (empty = no RAG)
*/
private function askChat(string $question, string $model, array $collections, int $limit, ?string $stylePrompt = null, ?string $customSystemPrompt = null, float $temperature = 0.7, int $maxTokens = 4096): array
{
try {
return $this->chatService->chat($question, $model, $collections, $limit, $stylePrompt, $customSystemPrompt, $temperature, $maxTokens);
} catch (\RuntimeException $e) {
throw new \RuntimeException('Chat-Service konnte nicht ausgeführt werden: ' . $e->getMessage(), 0, $e);
}
}
/**
* Render chat response HTML
*/
private function renderResponse(string $question, array $result, string $model): void
{
$answer = $result['answer'] ?? '';
$sources = $result['sources'] ?? [];
$usage = $result['usage'] ?? null;
$modelLabel = str_starts_with($model, 'ollama:') ? substr($model, 7) : $model;
// Convert markdown-style formatting to HTML
$answer = $this->formatAnswer($answer);
echo '';
echo '
' . htmlspecialchars($question) . '
';
echo '
';
echo '';
echo '
' . $answer . '
';
if (count($sources) > 0) {
$sourceCount = count($sources);
$uniqueId = uniqid('sources-');
echo '
';
echo '
';
echo '' . $sourceCount . ' Quelle' . ($sourceCount > 1 ? 'n' : '') . ' ';
echo '▼ ';
echo ' ';
echo '
';
foreach ($sources as $source) {
$title = htmlspecialchars($source['title'] ?? 'Unbekannt');
$score = round(($source['score'] ?? 0) * 100);
$content = isset($source['content']) ? htmlspecialchars(mb_substr($source['content'], 0, 200)) : '';
echo '
';
echo '';
if ($content !== '') {
echo '
"' . $content . (mb_strlen($source['content'] ?? '') > 200 ? '...' : '') . '"
';
}
echo '
';
}
echo '
';
echo $this->renderMessageMeta($modelLabel, $usage, $model);
echo '
';
} else {
echo $this->renderMessageMeta($modelLabel, $usage, $model);
}
echo '
';
}
/**
* Render message meta (model, tokens, cost)
*/
private function renderMessageMeta(string $modelLabel, ?array $usage, string $model): string
{
$html = '';
$html .= 'Modell: ' . htmlspecialchars($modelLabel) . ' ';
if (str_starts_with($model, 'claude-') && $usage !== null) {
$inputTokens = $usage['input_tokens'] ?? 0;
$outputTokens = $usage['output_tokens'] ?? 0;
$cost = $this->calculateCost($inputTokens, $outputTokens);
$html .= '';
$html .= '↓' . number_format($inputTokens) . ' ';
$html .= '↑' . number_format($outputTokens) . ' ';
$html .= ' ';
$html .= '~$' . number_format($cost, 4) . ' ';
} elseif (str_starts_with($model, 'ollama:')) {
$html .= 'lokal ';
}
$html .= '
';
return $html;
}
/**
* Calculate cost for Claude API usage
* Opus 4.5 Pricing: $15 per 1M input, $75 per 1M output
*/
private function calculateCost(int $inputTokens, int $outputTokens): float
{
$inputCost = $inputTokens * 0.000015; // $15 per 1M
$outputCost = $outputTokens * 0.000075; // $75 per 1M
return $inputCost + $outputCost;
}
/**
* Format markdown-like answer to HTML.
*
* Converts basic markdown syntax to HTML. Uses line-based processing
* to correctly handle list blocks mixed with regular text.
*/
private function formatAnswer(string $text): string
{
// Escape HTML first
$text = htmlspecialchars($text);
// Headers
$text = preg_replace('/^### (.+)$/m', '$1 ', $text);
$text = preg_replace('/^## (.+)$/m', '$1 ', $text);
$text = preg_replace('/^# (.+)$/m', '$1 ', $text);
// Bold
$text = preg_replace('/\*\*(.+?)\*\*/', '$1 ', $text);
// Italic
$text = preg_replace('/\*(.+?)\*/', '$1 ', $text);
// Code
$text = preg_replace('/`(.+?)`/', '$1', $text);
// Blockquotes
$text = preg_replace('/^> (.+)$/m', '$1 ', $text);
// Lists: Process line by line to correctly wrap consecutive list items
$text = $this->formatLists($text);
// Line breaks
$text = nl2br($text);
// Clean up multiple tags
$text = preg_replace('/( \s*){3,}/', ' ', $text);
return $text;
}
/**
* Format list items and wrap consecutive items in tags.
*
* Processes text line by line to correctly handle:
* - Multiple separate lists with text between them
* - Mixed content (text + list + text + list)
* - Numbered lists (converted to unordered for simplicity)
*/
private function formatLists(string $text): string
{
$lines = explode("\n", $text);
$result = [];
$inList = false;
foreach ($lines as $line) {
// Check if line is a list item (- or * or numbered)
if (preg_match('/^[-*] (.+)$/', $line, $matches)) {
if (!$inList) {
$result[] = '';
$inList = true;
}
$result[] = '' . $matches[1] . ' ';
} elseif (preg_match('/^\d+\. (.+)$/', $line, $matches)) {
// Numbered list item
if (!$inList) {
$result[] = '';
$inList = true;
}
$result[] = '' . $matches[1] . ' ';
} else {
// Not a list item
if ($inList) {
$result[] = ' ';
$inList = false;
}
$result[] = $line;
}
}
// Close any open list
if ($inList) {
$result[] = ' ';
}
return implode("\n", $result);
}
}