';
}
}
/**
* POST /chat/{uuid}/title (HTMX)
* Update session title
*/
public function updateTitle(string $uuid): void
{
$session = $this->getSession($uuid);
if ($session === null) {
http_response_code(404);
echo '';
return;
}
$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) {
http_response_code(404);
echo '
Session nicht gefunden.
';
return;
}
$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) {
http_response_code(404);
echo '
Session nicht gefunden.
';
return;
}
$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 ki_content database
*/
private function getAuthorProfiles(): array
{
$stmt = $this->db->query('SELECT id, name, slug, config FROM author_profiles WHERE is_active = 1 ORDER BY id');
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Get author profile by ID
*/
private function getAuthorProfile(int $profileId): ?array
{
$profiles = $this->getAuthorProfiles();
foreach ($profiles as $profile) {
if ((int) $profile['id'] === $profileId) {
return $profile;
}
}
return 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 $collection = 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, collection, context_limit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$sessionId,
$role,
$content,
$model,
$tokensInput,
$tokensOutput,
$sources !== [] ? json_encode($sources) : null,
$startMicrotime,
$endMicrotime,
$authorProfileId,
$systemPromptId,
$collection,
$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
* Format: claude-* or ollama:*
*/
private function validateModel(string $model): string
{
if (str_starts_with($model, 'claude-') || str_starts_with($model, 'ollama:')) {
return $model;
}
return 'claude-opus-4-5-20251101';
}
/**
* Get available Qdrant collections (cached per request)
*
* @return array List of collection names
*/
private function getAvailableCollections(): array
{
if ($this->collectionsCache === null) {
$this->collectionsCache = $this->qdrantService->listCollections();
// Fallback if Qdrant is unavailable
if ($this->collectionsCache === []) {
$this->collectionsCache = ['documents'];
}
}
return $this->collectionsCache;
}
/**
* 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
{
$availableCollections = $this->getAvailableCollections();
// 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, $availableCollections, true));
return array_values($valid);
}
/**
* 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, collection, context limit, author profile, temperature and max_tokens settings
*/
private function updateSessionSettings(int $sessionId, string $model, string $collection, int $contextLimit = 5, int $authorProfileId = 0, float $temperature = 0.7, int $maxTokens = 4096): void
{
$stmt = $this->db->prepare('UPDATE chat_sessions SET model = ?, collection = ?, context_limit = ?, author_profile_id = ?, temperature = ?, max_tokens = ? WHERE id = ?');
$stmt->execute([$model, $collection, $contextLimit, $authorProfileId > 0 ? $authorProfileId : null, $temperature, $maxTokens, $sessionId]);
}
/**
* Validate and sanitize system prompt
*/
private function validateSystemPrompt(string $prompt): string
{
// Max length: 2000 characters
$prompt = mb_substr($prompt, 0, 2000);
// Remove potentially dangerous patterns
$dangerousPatterns = [
'/ignore\s+(all\s+)?previous\s+instructions/i',
'/forget\s+(all\s+)?previous/i',
'/disregard\s+(all\s+)?instructions/i',
'/system\s*:\s*override/i',
];
foreach ($dangerousPatterns as $pattern) {
$prompt = preg_replace($pattern, '[removed]', $prompt) ?? $prompt;
}
return trim($prompt);
}
/**
* 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
*/
private function askChat(string $question, string $model, string $collection, int $limit, ?string $stylePrompt = null, ?string $customSystemPrompt = null, float $temperature = 0.7, int $maxTokens = 4096): array
{
try {
return $this->chatService->chat($question, $model, $collection, $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 '