Skip to content

Commit 6d8fbbb

Browse files
committed
feat: improve markdown formatting and add AI configuration to init command
- Fix markdown formatter: preserve asterisks and underscores in code blocks - Fix markdown formatter: prevent underscores in words (e.g. users_created_at) from being formatted as italic - Fix markdown formatter: process lists before italic formatting to avoid conflicts - Fix markdown formatter: improve code block border alignment - Fix: add missing DeepSeek API key loading from environment variables - feat: add AI configuration to pdodb init command - Add askAiConfiguration() method to InitWizard - Add loadAiConfigFromEnv() for non-interactive mode - Update InitConfigGenerator to include AI settings in .env and config/db.php - Support all AI providers (openai, anthropic, google, microsoft, ollama, deepseek)
1 parent 8f20d9f commit 6d8fbbb

File tree

4 files changed

+234
-19
lines changed

4 files changed

+234
-19
lines changed

src/ai/AiConfig.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ protected function loadFromEnvironment(): void
108108
$this->apiKeys['microsoft'] = $microsoftKey;
109109
}
110110

111+
$deepseekKey = getenv('PDODB_AI_DEEPSEEK_KEY');
112+
if ($deepseekKey !== false && $deepseekKey !== '') {
113+
$this->apiKeys['deepseek'] = $deepseekKey;
114+
}
115+
111116
$ollamaUrl = getenv('PDODB_AI_OLLAMA_URL');
112117
if ($ollamaUrl !== false && $ollamaUrl !== '') {
113118
$this->ollamaUrl = $ollamaUrl;

src/cli/InitConfigGenerator.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,36 @@ public static function generateEnv(array $config, array $structure, string $path
8080
$lines[] = '';
8181
}
8282

83+
// AI configuration
84+
if (isset($config['ai']) && is_array($config['ai'])) {
85+
$lines[] = '# AI Configuration';
86+
if (isset($config['ai']['provider'])) {
87+
$lines[] = 'PDODB_AI_PROVIDER=' . $config['ai']['provider'];
88+
}
89+
90+
$providers = ['openai', 'anthropic', 'google', 'microsoft', 'deepseek', 'ollama'];
91+
foreach ($providers as $provider) {
92+
$keyName = $provider . '_key';
93+
if (isset($config['ai'][$keyName])) {
94+
$lines[] = 'PDODB_AI_' . strtoupper($provider) . '_KEY=' . $config['ai'][$keyName];
95+
}
96+
}
97+
98+
if (isset($config['ai']['ollama_url'])) {
99+
$lines[] = 'PDODB_AI_OLLAMA_URL=' . $config['ai']['ollama_url'];
100+
}
101+
102+
// Provider-specific settings
103+
if (isset($config['ai']['providers']) && is_array($config['ai']['providers'])) {
104+
foreach ($config['ai']['providers'] as $provider => $settings) {
105+
if (is_array($settings) && isset($settings['model'])) {
106+
$lines[] = 'PDODB_AI_' . strtoupper($provider) . '_MODEL=' . $settings['model'];
107+
}
108+
}
109+
}
110+
$lines[] = '';
111+
}
112+
83113
// Project paths
84114
if (!empty($structure)) {
85115
$lines[] = '# Project Paths';
@@ -237,6 +267,11 @@ protected static function buildSingleConnectionArray(array $config): array
237267
$result['enable_regexp'] = $config['enable_regexp'];
238268
}
239269

270+
// AI configuration
271+
if (isset($config['ai']) && is_array($config['ai'])) {
272+
$result['ai'] = $config['ai'];
273+
}
274+
240275
return $result;
241276
}
242277

src/cli/InitWizard.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ protected function loadConfigFromEnv(): void
168168
}
169169
}
170170
}
171+
172+
// Load AI configuration if enabled
173+
$aiProvider = getenv('PDODB_AI_PROVIDER');
174+
if ($aiProvider !== false && $aiProvider !== '') {
175+
$this->config['ai'] = ['provider' => mb_strtolower($aiProvider, 'UTF-8')];
176+
$this->loadAiConfigFromEnv();
177+
}
171178
}
172179

173180
/**
@@ -185,6 +192,45 @@ protected function loadStructureFromEnv(): void
185192
];
186193
}
187194

195+
/**
196+
* Load AI configuration from environment variables.
197+
*/
198+
protected function loadAiConfigFromEnv(): void
199+
{
200+
if (!isset($this->config['ai']) || !is_array($this->config['ai'])) {
201+
$this->config['ai'] = [];
202+
}
203+
204+
$providers = ['openai', 'anthropic', 'google', 'microsoft', 'deepseek', 'ollama'];
205+
foreach ($providers as $provider) {
206+
$envVar = 'PDODB_AI_' . strtoupper($provider) . '_KEY';
207+
$key = getenv($envVar);
208+
if ($key !== false && $key !== '') {
209+
$this->config['ai'][$provider . '_key'] = $key;
210+
}
211+
}
212+
213+
$ollamaUrl = getenv('PDODB_AI_OLLAMA_URL');
214+
if ($ollamaUrl !== false && $ollamaUrl !== '') {
215+
$this->config['ai']['ollama_url'] = $ollamaUrl;
216+
}
217+
218+
// Load provider-specific settings
219+
if (!isset($this->config['ai']['providers'])) {
220+
$this->config['ai']['providers'] = [];
221+
}
222+
foreach ($providers as $provider) {
223+
$modelVar = 'PDODB_AI_' . strtoupper($provider) . '_MODEL';
224+
$model = getenv($modelVar);
225+
if ($model !== false && $model !== '') {
226+
if (!isset($this->config['ai']['providers'][$provider])) {
227+
$this->config['ai']['providers'][$provider] = [];
228+
}
229+
$this->config['ai']['providers'][$provider]['model'] = $model;
230+
}
231+
}
232+
}
233+
188234
/**
189235
* Ask for basic database connection settings.
190236
*/
@@ -447,6 +493,7 @@ protected function askAdvancedOptions(): void
447493
$this->askTablePrefix();
448494
$this->askCaching();
449495
$this->askPerformance();
496+
$this->askAiConfiguration();
450497
$this->askMultipleConnections();
451498
}
452499

@@ -623,6 +670,73 @@ protected function askPerformance(): void
623670
}
624671
}
625672

673+
/**
674+
* Ask AI configuration.
675+
*/
676+
protected function askAiConfiguration(): void
677+
{
678+
echo "\n AI Configuration\n";
679+
echo " ----------------\n";
680+
$enableAi = static::readConfirmation(' Enable AI-powered database analysis?', false);
681+
if (!$enableAi) {
682+
return;
683+
}
684+
685+
if (!isset($this->config['ai'])) {
686+
$this->config['ai'] = [];
687+
}
688+
689+
$providers = ['openai', 'anthropic', 'google', 'microsoft', 'ollama', 'deepseek'];
690+
echo " Available providers: " . implode(', ', $providers) . "\n";
691+
$provider = static::readInput(' Default AI provider', 'openai');
692+
$provider = mb_strtolower(trim($provider), 'UTF-8');
693+
if (!in_array($provider, $providers, true)) {
694+
$provider = 'openai';
695+
}
696+
$this->config['ai']['provider'] = $provider;
697+
698+
// Ask for API keys
699+
echo "\n API Keys (leave empty to skip)\n";
700+
foreach ($providers as $p) {
701+
if ($p === 'ollama') {
702+
// Ollama doesn't need API key, but ask for URL
703+
$url = static::readInput(" Ollama URL", 'http://localhost:11434');
704+
if ($url !== '') {
705+
$this->config['ai']['ollama_url'] = $url;
706+
}
707+
} else {
708+
$key = static::readPassword(" {$p} API key (optional, press Enter to skip)");
709+
if ($key !== '') {
710+
$this->config['ai'][$p . '_key'] = $key;
711+
}
712+
}
713+
}
714+
715+
// Ask for provider-specific settings
716+
$configureProviderSettings = static::readConfirmation(' Configure provider-specific settings (model, temperature, etc.)?', false);
717+
if ($configureProviderSettings) {
718+
if (!isset($this->config['ai']['providers'])) {
719+
$this->config['ai']['providers'] = [];
720+
}
721+
722+
foreach ($providers as $p) {
723+
$hasKey = isset($this->config['ai'][$p . '_key']) || ($p === 'ollama');
724+
if (!$hasKey) {
725+
continue;
726+
}
727+
728+
echo "\n {$p} settings:\n";
729+
$model = static::readInput(" Model (optional)", '');
730+
if ($model !== '') {
731+
if (!isset($this->config['ai']['providers'][$p])) {
732+
$this->config['ai']['providers'][$p] = [];
733+
}
734+
$this->config['ai']['providers'][$p]['model'] = $model;
735+
}
736+
}
737+
}
738+
}
739+
626740
/**
627741
* Ask multiple connections configuration.
628742
*/

src/cli/MarkdownFormatter.php

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,58 @@ public function format(string $markdown): string
3232
{
3333
$text = $markdown;
3434

35-
// Remove markdown code blocks but keep content
36-
$text = preg_replace_callback('/```(\w+)?\n(.*?)```/s', function (array $matches): string {
35+
// First, protect code blocks (```code```) by replacing them with placeholders
36+
$codeBlocks = [];
37+
$codeBlockIndex = 0;
38+
$text = preg_replace_callback('/```(\w+)?\n(.*?)```/s', function (array $matches) use (&$codeBlocks, &$codeBlockIndex): string {
3739
// preg_replace_callback guarantees indices 1 and 2 exist for this pattern
3840
$code = $matches[2];
3941
$lang = $matches[1] !== '' ? $matches[1] : '';
40-
return $this->formatCodeBlock($code, $lang);
42+
$placeholder = "\x00CODEBLOCK" . $codeBlockIndex . "\x00";
43+
$codeBlocks[$placeholder] = $this->formatCodeBlock($code, $lang);
44+
$codeBlockIndex++;
45+
return $placeholder;
4146
}, $text);
4247
if ($text === null) {
4348
$text = $markdown;
4449
}
50+
$text = (string)$text;
4551

46-
// Format inline code
47-
$replaced = preg_replace('/`([^`]+)`/', $this->useColors ? "\033[36m\$1\033[0m" : '[$1]', $text);
48-
$text = $replaced !== null ? $replaced : $text;
52+
// Protect inline code blocks (`code`) by replacing them with placeholders
53+
// This protects content from markdown formatting (like asterisks and underscores)
54+
$inlineCode = [];
55+
$inlineCodeIndex = 0;
56+
57+
// First, handle double backticks (``code``) - these can contain single backticks
58+
// Pattern: `` followed by content (which can include `) followed by ``
59+
// Match the shortest possible sequence to avoid greedy matching
60+
$text = preg_replace_callback('/``(.*?)``/s', function (array $matches) use (&$inlineCode, &$inlineCodeIndex): string {
61+
// preg_replace_callback guarantees index 1 exists
62+
$code = $matches[1];
63+
$placeholder = "\x00INLINECODE" . $inlineCodeIndex . "\x00";
64+
$inlineCode[$placeholder] = $this->useColors ? "\033[36m{$code}\033[0m" : "[{$code}]";
65+
$inlineCodeIndex++;
66+
return $placeholder;
67+
}, $text);
68+
if ($text === null) {
69+
$text = '';
70+
}
71+
$text = (string)$text;
72+
73+
// Then handle single backticks (`code`) - these cannot contain backticks
74+
// Use non-greedy matching to handle cases correctly
75+
$text = preg_replace_callback('/`([^`\n]+?)`/', function (array $matches) use (&$inlineCode, &$inlineCodeIndex): string {
76+
// preg_replace_callback guarantees index 1 exists
77+
$code = $matches[1];
78+
$placeholder = "\x00INLINECODE" . $inlineCodeIndex . "\x00";
79+
$inlineCode[$placeholder] = $this->useColors ? "\033[36m{$code}\033[0m" : "[{$code}]";
80+
$inlineCodeIndex++;
81+
return $placeholder;
82+
}, $text);
83+
if ($text === null) {
84+
$text = '';
85+
}
86+
$text = (string)$text;
4987

5088
// Format headers
5189
$text = (string)preg_replace('/^### (.*)$/m', $this->formatHeader(3, '$1'), $text);
@@ -56,23 +94,38 @@ public function format(string $markdown): string
5694
$text = (string)preg_replace('/\*\*(.+?)\*\*/', $this->useColors ? "\033[1m\$1\033[0m" : '**$1**', $text);
5795
$text = (string)preg_replace('/__(.+?)__/', $this->useColors ? "\033[1m\$1\033[0m" : '__$1__', $text);
5896

59-
// Format italic text
60-
$text = (string)preg_replace('/\*(.+?)\*/', $this->useColors ? "\033[3m\$1\033[0m" : '*$1*', $text);
61-
$text = (string)preg_replace('/_(.+?)_/', $this->useColors ? "\033[3m\$1\033[0m" : '_$1_', $text);
62-
63-
// Format unordered lists
64-
$text = preg_replace_callback('/^(\s*)[-*+] (.+)$/m', function (array $matches): string {
97+
// Format unordered lists BEFORE italic formatting to avoid conflicts
98+
// This ensures list markers are processed before italic markers
99+
$replaced = preg_replace_callback('/^(\s*)[-*+] (.+)$/m', function (array $matches): string {
65100
// preg_replace_callback guarantees these indices exist
66101
$indent = $matches[1];
67102
$content = $matches[2];
68103
$bullet = $this->useColors ? "\033[0;33m•\033[0m" : '';
69104
return $indent . $bullet . ' ' . $content;
70105
}, $text);
71-
if ($text === null) {
72-
$text = '';
73-
}
106+
$text = $replaced !== null ? $replaced : $text;
74107
$text = (string)$text;
75108

109+
// Format italic text AFTER lists to avoid interfering with list markers
110+
// Only format if underscores are around words (not inside words like users_created_at)
111+
// Pattern: _word_ but not word_word or _word_word
112+
// Exclude asterisks that are list markers (at start of line with optional whitespace)
113+
$replaced = preg_replace('/(?<!^|\n)(?<!\S)\*(.+?)\*(?!\S)/', $this->useColors ? "\033[3m\$1\033[0m" : '*$1*', $text);
114+
$text = $replaced !== null ? $replaced : $text;
115+
// Match _text_ only if not preceded/followed by word characters (letters, digits, underscores)
116+
$replaced = preg_replace('/(?<![a-zA-Z0-9_])_(.+?)_(?![a-zA-Z0-9_])/', $this->useColors ? "\033[3m\$1\033[0m" : '_$1_', $text);
117+
$text = $replaced !== null ? $replaced : $text;
118+
119+
// Restore inline code blocks
120+
foreach ($inlineCode as $placeholder => $formatted) {
121+
$text = str_replace($placeholder, $formatted, $text);
122+
}
123+
124+
// Restore code blocks
125+
foreach ($codeBlocks as $placeholder => $formatted) {
126+
$text = str_replace($placeholder, $formatted, $text);
127+
}
128+
76129
// Format ordered lists
77130
$text = preg_replace_callback('/^(\s*)(\d+)\. (.+)$/m', function (array $matches): string {
78131
// preg_replace_callback guarantees these indices exist
@@ -363,26 +416,34 @@ protected function formatCodeBlock(string $code, string $lang = ''): string
363416
$maxLength = max($maxLength, mb_strlen($line));
364417
}
365418

366-
$border = str_repeat('', min($maxLength + 2, 80));
367419
$langLabel = $lang !== '' ? " {$lang}" : '';
420+
// Calculate border width: max of code width + 2 (for padding) and lang label width + 2
421+
// But ensure it doesn't exceed 80 characters
422+
$langWidth = $lang !== '' ? mb_strlen($langLabel) : 0;
423+
$borderWidth = min(max($maxLength + 2, $langWidth + 2), 80);
424+
$border = str_repeat('', $borderWidth);
368425

369426
$result = [];
370427
if ($this->useColors) {
371428
$result[] = "\033[0;90m┌{$border}\033[0m";
372429
if ($lang !== '') {
373-
$result[] = "\033[0;90m│\033[0m\033[0;36m{$langLabel}\033[0m" . str_repeat(' ', max(0, $maxLength - mb_strlen($lang) + 1)) . "\033[0;90m│\033[0m";
430+
$langPadding = max(0, $borderWidth - mb_strlen($langLabel) - 2);
431+
$result[] = "\033[0;90m│\033[0m\033[0;36m{$langLabel}\033[0m" . str_repeat(' ', $langPadding) . "\033[0;90m│\033[0m";
374432
$result[] = "\033[0;90m├{$border}\033[0m";
375433
}
376434
} else {
377435
$result[] = "{$border}";
378436
if ($lang !== '') {
379-
$result[] = "{$langLabel}" . str_repeat(' ', max(0, $maxLength - mb_strlen($lang) + 1)) . '';
437+
$langPadding = max(0, $borderWidth - mb_strlen($langLabel) - 2);
438+
$result[] = "{$langLabel}" . str_repeat(' ', $langPadding) . '';
380439
$result[] = "{$border}";
381440
}
382441
}
383442

384443
foreach ($lines as $line) {
385-
$padded = $line . str_repeat(' ', max(0, $maxLength - mb_strlen($line)));
444+
// Pad line to border width - 2 (for left and right padding spaces)
445+
$linePadding = max(0, $borderWidth - mb_strlen($line) - 2);
446+
$padded = $line . str_repeat(' ', $linePadding);
386447
if ($this->useColors) {
387448
$result[] = "\033[0;90m│\033[0m \033[36m{$padded}\033[0m \033[0;90m│\033[0m";
388449
} else {

0 commit comments

Comments
 (0)