Skip to content

Commit e81bdee

Browse files
committed
fix: use configuration-based LLM routing, restore default RTE toolbar, fix Ollama pull
- Cowriter.yaml: import TYPO3 default RTE configs (Processing, Base, Plugins) and extend toolbar with cowriter button instead of replacing entire config - AjaxController: use chatWithConfiguration/streamChatWithConfiguration for database-backed provider resolution (fixes localhost vs ollama endpoint) - Remove unused createChatOptions method and related constants - Ollama host command: add TTY detection for non-interactive model pull - Makefile: remove stderr suppression from ollama-pull target Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
1 parent bb37791 commit e81bdee

File tree

8 files changed

+213
-843
lines changed

8 files changed

+213
-843
lines changed

.ddev/commands/host/ollama

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ case "$1" in
1313
pull)
1414
MODEL="${2:-$DEFAULT_MODEL}"
1515
echo "Pulling model: $MODEL..."
16-
docker exec -it "$OLLAMA_CONTAINER" ollama pull "$MODEL"
16+
if [ -t 0 ]; then
17+
docker exec -it "$OLLAMA_CONTAINER" ollama pull "$MODEL"
18+
else
19+
docker exec "$OLLAMA_CONTAINER" ollama pull "$MODEL"
20+
fi
1721
;;
1822
list)
1923
echo "Available models:"
@@ -22,15 +26,23 @@ case "$1" in
2226
run|chat)
2327
MODEL="${2:-$DEFAULT_MODEL}"
2428
echo "Starting chat with: $MODEL (Ctrl+D to exit)"
25-
docker exec -it "$OLLAMA_CONTAINER" ollama run "$MODEL"
29+
if [ -t 0 ]; then
30+
docker exec -it "$OLLAMA_CONTAINER" ollama run "$MODEL"
31+
else
32+
docker exec -i "$OLLAMA_CONTAINER" ollama run "$MODEL"
33+
fi
2634
;;
2735
rm|remove)
2836
if [ -z "$2" ]; then
2937
echo "Usage: ddev ollama rm <model>"
3038
exit 1
3139
fi
3240
echo "Removing model: $2..."
33-
docker exec -it "$OLLAMA_CONTAINER" ollama rm "$2"
41+
if [ -t 0 ]; then
42+
docker exec -it "$OLLAMA_CONTAINER" ollama rm "$2"
43+
else
44+
docker exec "$OLLAMA_CONTAINER" ollama rm "$2"
45+
fi
3446
;;
3547
status)
3648
echo "Ollama container status:"

Classes/Controller/AjaxController.php

Lines changed: 16 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
use Netresearch\NrLlm\Domain\Repository\LlmConfigurationRepository;
1515
use Netresearch\NrLlm\Provider\Exception\ProviderException;
1616
use Netresearch\NrLlm\Service\LlmServiceManagerInterface;
17-
use Netresearch\NrLlm\Service\Option\ChatOptions;
1817
use Netresearch\T3Cowriter\Domain\DTO\CompleteRequest;
1918
use Netresearch\T3Cowriter\Domain\DTO\CompleteResponse;
2019
use Netresearch\T3Cowriter\Service\RateLimiterInterface;
@@ -55,16 +54,6 @@
5554
*/
5655
private const ALLOWED_ROLES = ['user', 'assistant'];
5756

58-
/**
59-
* Maximum tokens upper bound to prevent denial-of-wallet attacks.
60-
*/
61-
private const MAX_TOKENS_UPPER_BOUND = 16384;
62-
63-
/**
64-
* Maximum number of stop sequences allowed.
65-
*/
66-
private const MAX_STOP_SEQUENCES = 10;
67-
6857
/**
6958
* System prompt for the cowriter assistant.
7059
*/
@@ -88,7 +77,7 @@ public function __construct(
8877
*
8978
* Expects JSON body with:
9079
* - messages: array of {role: string, content: string}
91-
* - options: optional array with temperature, maxTokens, etc.
80+
* - configuration: optional configuration identifier
9281
*/
9382
public function chatAction(ServerRequestInterface $request): ResponseInterface
9483
{
@@ -114,9 +103,6 @@ public function chatAction(ServerRequestInterface $request): ResponseInterface
114103
}
115104

116105
$rawMessages = isset($body['messages']) && is_array($body['messages']) ? $body['messages'] : [];
117-
/** @var array<string, mixed> $optionsData */
118-
$optionsData = isset($body['options']) && is_array($body['options']) ? $body['options'] : [];
119-
$options = $this->createChatOptions($optionsData);
120106

121107
if ($rawMessages === []) {
122108
return new JsonResponse(['success' => false, 'error' => 'Messages array is required'], 400);
@@ -131,8 +117,19 @@ public function chatAction(ServerRequestInterface $request): ResponseInterface
131117
);
132118
}
133119

120+
// Resolve configuration from request or fall back to default
121+
$configIdentifier = isset($body['configuration']) && is_string($body['configuration']) ? $body['configuration'] : null;
122+
$configuration = $this->resolveConfiguration($configIdentifier);
123+
if (!$configuration instanceof LlmConfiguration) {
124+
return $this->jsonResponseWithRateLimitHeaders(
125+
['success' => false, 'error' => 'No LLM configuration available. Please configure the nr_llm extension.'],
126+
$rateLimitResult,
127+
404,
128+
);
129+
}
130+
134131
try {
135-
$response = $this->llmServiceManager->chat($messages, $options);
132+
$response = $this->llmServiceManager->chatWithConfiguration($messages, $configuration);
136133

137134
// Escape HTML to prevent XSS attacks (defense in depth for all string values)
138135
return $this->jsonResponseWithRateLimitHeaders([
@@ -227,14 +224,7 @@ private function executeCompletion(
227224
['role' => 'user', 'content' => $dto->prompt],
228225
];
229226

230-
$options = $configuration->toChatOptions();
231-
232-
// Apply model override if specified via #cw:model-name prefix
233-
if ($dto->modelOverride !== null) {
234-
$options = $options->withModel($dto->modelOverride);
235-
}
236-
237-
$response = $this->llmServiceManager->chat($messages, $options);
227+
$response = $this->llmServiceManager->chatWithConfiguration($messages, $configuration);
238228

239229
// CompleteResponse.success() handles HTML escaping
240230
return $this->jsonResponseWithRateLimitHeaders(
@@ -304,31 +294,18 @@ public function streamAction(ServerRequestInterface $request): ResponseInterface
304294
);
305295
}
306296

307-
// Check if streaming is supported
308-
if (!$this->llmServiceManager->supportsFeature('streaming')) {
309-
// Fall back to non-streaming completion (reuse already-parsed DTO and rate limit)
310-
return $this->executeCompletion($dto, $configuration, $rateLimitResult);
311-
}
312-
313297
// Build the streaming response using a generator
314298
$messages = [
315299
['role' => 'system', 'content' => self::SYSTEM_PROMPT],
316300
['role' => 'user', 'content' => $dto->prompt],
317301
];
318302

319-
$options = $configuration->toChatOptions();
320-
321-
// Apply model override if specified via #cw:model-name prefix
322-
if ($dto->modelOverride !== null) {
323-
$options = $options->withModel($dto->modelOverride);
324-
}
325-
326303
// Collect all chunks and return as SSE-formatted response
327304
// Note: True streaming requires output buffering disabled which isn't always possible in TYPO3
328305
// This implementation collects chunks and returns them in SSE format for compatibility
329306
try {
330307
$chunks = [];
331-
$generator = $this->llmServiceManager->streamChat($messages, $options);
308+
$generator = $this->llmServiceManager->streamChatWithConfiguration($messages, $configuration);
332309

333310
foreach ($generator as $chunk) {
334311
$sanitizedChunk = htmlspecialchars($chunk, ENT_QUOTES | ENT_HTML5, 'UTF-8');
@@ -338,7 +315,7 @@ public function streamAction(ServerRequestInterface $request): ResponseInterface
338315
// Add final "done" event
339316
$chunks[] = 'data: ' . json_encode([
340317
'done' => true,
341-
'model' => htmlspecialchars($options->getModel() ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8'),
318+
'model' => htmlspecialchars($configuration->getModelId(), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
342319
], JSON_THROW_ON_ERROR) . "\n\n";
343320

344321
$body = implode('', $chunks);
@@ -416,74 +393,6 @@ private function resolveConfiguration(?string $identifier): ?LlmConfiguration
416393
return $this->configurationRepository->findDefault();
417394
}
418395

419-
/**
420-
* Create ChatOptions from array of options with range validation.
421-
*
422-
* Validates and clamps numeric parameters to their valid ranges:
423-
* - temperature: 0.0 to 2.0
424-
* - topP: 0.0 to 1.0
425-
* - frequencyPenalty: -2.0 to 2.0
426-
* - presencePenalty: -2.0 to 2.0
427-
* - maxTokens: 1 to 16384
428-
*
429-
* Security: provider, model, and systemPrompt overrides from the client
430-
* are intentionally ignored. These are controlled server-side via
431-
* LlmConfiguration records in the nr-llm extension.
432-
*
433-
* @param array<string, mixed> $options
434-
*/
435-
private function createChatOptions(array $options): ?ChatOptions
436-
{
437-
if ($options === []) {
438-
return null;
439-
}
440-
441-
$temperature = isset($options['temperature']) && is_numeric($options['temperature'])
442-
? $this->clampFloat((float) $options['temperature'], 0.0, 2.0)
443-
: null;
444-
$maxTokens = isset($options['maxTokens']) && is_numeric($options['maxTokens'])
445-
? $this->clampInt((int) $options['maxTokens'], 1, self::MAX_TOKENS_UPPER_BOUND)
446-
: null;
447-
$topP = isset($options['topP']) && is_numeric($options['topP'])
448-
? $this->clampFloat((float) $options['topP'], 0.0, 1.0)
449-
: null;
450-
$frequencyPenalty = isset($options['frequencyPenalty']) && is_numeric($options['frequencyPenalty'])
451-
? $this->clampFloat((float) $options['frequencyPenalty'], -2.0, 2.0)
452-
: null;
453-
$presencePenalty = isset($options['presencePenalty']) && is_numeric($options['presencePenalty'])
454-
? $this->clampFloat((float) $options['presencePenalty'], -2.0, 2.0)
455-
: null;
456-
$responseFormat = isset($options['responseFormat']) && is_string($options['responseFormat'])
457-
&& in_array($options['responseFormat'], ['text', 'json', 'markdown'], true)
458-
? $options['responseFormat']
459-
: null;
460-
$stopSequences = null;
461-
if (isset($options['stopSequences']) && is_array($options['stopSequences'])) {
462-
$filtered = array_values(array_filter(
463-
array_slice($options['stopSequences'], 0, self::MAX_STOP_SEQUENCES),
464-
is_string(...),
465-
));
466-
$stopSequences = $filtered !== [] ? $filtered : null;
467-
}
468-
469-
// If none of the recognized options had values, treat as "no options"
470-
if ($temperature === null && $maxTokens === null && $topP === null
471-
&& $frequencyPenalty === null && $presencePenalty === null
472-
&& $responseFormat === null && $stopSequences === null) {
473-
return null;
474-
}
475-
476-
return new ChatOptions(
477-
temperature: $temperature,
478-
maxTokens: $maxTokens,
479-
topP: $topP,
480-
frequencyPenalty: $frequencyPenalty,
481-
presencePenalty: $presencePenalty,
482-
responseFormat: $responseFormat,
483-
stopSequences: $stopSequences,
484-
);
485-
}
486-
487396
/**
488397
* Validate and sanitize chat messages.
489398
*
@@ -526,22 +435,6 @@ private function validateMessages(array $rawMessages): ?array
526435
return $validated;
527436
}
528437

529-
/**
530-
* Clamp a float value to a range.
531-
*/
532-
private function clampFloat(float $value, float $min, float $max): float
533-
{
534-
return max($min, min($max, $value));
535-
}
536-
537-
/**
538-
* Clamp an integer value to a range.
539-
*/
540-
private function clampInt(int $value, int $min, int $max): int
541-
{
542-
return max($min, min($max, $value));
543-
}
544-
545438
/**
546439
* Check rate limit for current backend user.
547440
*/

Configuration/RTE/Cowriter.yaml

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,70 @@
1+
imports:
2+
- { resource: 'EXT:rte_ckeditor/Configuration/RTE/Processing.yaml' }
3+
- { resource: 'EXT:rte_ckeditor/Configuration/RTE/Editor/Base.yaml' }
4+
- { resource: 'EXT:rte_ckeditor/Configuration/RTE/Editor/Plugins.yaml' }
5+
16
editor:
27
config:
38
importModules:
49
- { module: '@netresearch/t3_cowriter/cowriter', exports: ['Cowriter'] }
10+
511
toolbar:
612
items:
13+
- style
14+
- heading
15+
- '|'
716
- bold
817
- italic
18+
- subscript
19+
- superscript
20+
- softhyphen
21+
- '|'
22+
- bulletedList
23+
- numberedList
24+
- blockQuote
25+
- alignment
26+
- '|'
27+
- findAndReplace
28+
- link
929
- '|'
10-
- clipboard
30+
- removeFormat
1131
- undo
1232
- redo
33+
- '|'
34+
- insertTable
35+
- '|'
36+
- specialCharacters
37+
- horizontalLine
38+
- sourceEditing
39+
- '|'
1340
- cowriter
41+
42+
heading:
43+
options:
44+
- { model: 'paragraph', title: 'Paragraph' }
45+
- { model: 'heading2', view: 'h2', title: 'Heading 2' }
46+
- { model: 'heading3', view: 'h3', title: 'Heading 3' }
47+
- { model: 'formatted', view: 'pre', title: 'Pre-Formatted Text' }
48+
49+
style:
50+
definitions:
51+
- { name: "Lead", element: "p", classes: ['lead'] }
52+
- { name: "Small", element: "small" }
53+
- { name: "Muted", element: "span", classes: ['text-muted'] }
54+
55+
alignment:
56+
options:
57+
- { name: 'left', className: 'text-start' }
58+
- { name: 'center', className: 'text-center' }
59+
- { name: 'right', className: 'text-end' }
60+
- { name: 'justify', className: 'text-justify' }
61+
62+
table:
63+
defaultHeadings: { rows: 1 }
64+
contentToolbar:
65+
- tableColumn
66+
- tableRow
67+
- mergeTableCells
68+
- tableProperties
69+
- tableCellProperties
70+
- toggleTableCaption

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ seed: ## Import Ollama seed data (provider, models, configs)
128128

129129
.PHONY: ollama-pull
130130
ollama-pull: ## Pull default Ollama model (if not already present)
131-
@ddev ollama pull 2>/dev/null || echo "Ollama not ready yet. Pull manually: ddev ollama pull"
131+
@ddev ollama pull || echo "Ollama not ready yet. Pull manually: ddev ollama pull"
132132

133133
# ===================================
134134
# Cleanup

Tests/E2E/AbstractE2ETestCase.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ protected function createCompleteStack(array $responseQueue = []): array
6868
$serviceManager = $this->createMock(LlmServiceManagerInterface::class);
6969

7070
if ($responseQueue !== []) {
71-
$serviceManager->method('chat')
71+
$serviceManager->method('chatWithConfiguration')
7272
->willReturnOnConsecutiveCalls(...$responseQueue);
7373
}
7474

@@ -185,6 +185,7 @@ protected function createLlmConfiguration(
185185
$config->method('getIdentifier')->willReturn($identifier);
186186
$config->method('getName')->willReturn($name);
187187
$config->method('isDefault')->willReturn($isDefault);
188+
$config->method('getModelId')->willReturn($model);
188189
$config->method('toChatOptions')->willReturn($chatOptions);
189190

190191
return $config;

0 commit comments

Comments
 (0)