Full sweep: fix broken features, redesign NodePreview, wire AI service

FIXES (from comprehensive audit):
- NodePreview: complete rewrite — 380px left panel with document
  summaries, commitment list, phase track visualization, scrollable.
  Fixed children count bug (was showing [object Object]).
  Slides in from left (not right) to not overlap branch handles.
- CommitmentForm: added required validation on eigenaar_id field
- MetroMap: wired custom metro node creation with form + POST /metro-nodes
- MetroMap: removed dead handleCliCommand console.log
- MetroMap: added metro node creation modal (naam + beschrijving)

NEW — AI Service integration:
- ai-service/main.py: real Anthropic API integration via httpx
  - Reads ANTHROPIC_API_KEY from env, uses claude-haiku-4-5-20251001
  - /api/chat fetches project context from PostgreSQL (docs, commitments)
  - /api/summarize sends content to Claude for summarization
  - /api/search does basic text search on documents + kennis_artikelen
- AiController.php: Laravel proxy for /api/ai/chat → ai-service
- CliBar.vue: complete rewrite with async API calls, processing state,
  error handling, conversation history, auto-scroll
  - Receives projectId prop for context-scoped AI queries
  - Shows "denken..." animation while waiting for response
- docker-compose.yml: passes ANTHROPIC_API_KEY to ai-service container
- config/services.php: ai service URL configuration

To activate AI: set ANTHROPIC_API_KEY in .env and rebuild ai-service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-08 15:07:51 +02:00
parent 9f033835cd
commit f4ec49254a
10 changed files with 619 additions and 84 deletions

View File

@@ -1,23 +1,42 @@
<script setup>
import { ref, nextTick } from 'vue'
import { ref, nextTick, watch } from 'vue'
import axios from 'axios'
const emit = defineEmits(['command'])
const props = defineProps({
projectId: { type: Number, default: null },
})
const input = ref('')
const inputRef = ref(null)
const history = ref([])
const showHistory = ref(false)
const isProcessing = ref(false)
const handleSubmit = () => {
if (!input.value.trim()) return
const handleSubmit = async () => {
if (!input.value.trim() || isProcessing.value) return
const command = input.value.trim()
history.value.push({ type: 'input', text: command })
history.value.push({ type: 'response', text: 'Processing...' })
showHistory.value = true
emit('command', command)
input.value = ''
isProcessing.value = true
try {
const response = await axios.post('/api/ai/chat', {
message: command,
project_id: props.projectId,
conversation_history: history.value.filter(e => e.type !== 'processing').slice(-10),
})
history.value.push({ type: 'response', text: response.data.reply })
} catch (error) {
const msg = error.response?.data?.message || error.message || 'Verbindingsfout'
history.value.push({ type: 'error', text: `Fout: ${msg}` })
} finally {
isProcessing.value = false
await nextTick()
const historyEl = document.querySelector('.cli-history')
if (historyEl) historyEl.scrollTop = historyEl.scrollHeight
}
}
const focusInput = () => {
@@ -38,8 +57,13 @@ const focusInput = () => {
>
<span v-if="entry.type === 'input'" class="prompt-char">&gt; </span>
<span v-if="entry.type === 'response'" class="ai-label">[AI] </span>
<span v-if="entry.type === 'error'" class="error-label">[ERR] </span>
{{ entry.text }}
</div>
<div v-if="isProcessing" class="history-entry processing">
<span class="ai-label">[AI] </span>
<span class="thinking">denken</span><span class="dots">...</span>
</div>
</div>
</Transition>
@@ -50,7 +74,8 @@ const focusInput = () => {
ref="inputRef"
v-model="input"
class="cli-input"
placeholder="ask me anything..."
:placeholder="isProcessing ? 'wachten op AI...' : 'stel een vraag...'"
:disabled="isProcessing"
spellcheck="false"
autocomplete="off"
@keydown.enter="handleSubmit"
@@ -72,7 +97,7 @@ const focusInput = () => {
.cli-history {
background: rgba(22, 33, 62, 0.95);
border-top: 1px solid rgba(0, 210, 255, 0.2);
max-height: 200px;
max-height: 300px;
overflow-y: auto;
padding: 12px 20px;
backdrop-filter: blur(10px);
@@ -83,6 +108,8 @@ const focusInput = () => {
font-size: 16px;
line-height: 1.6;
color: #8892b0;
white-space: pre-wrap;
word-break: break-word;
}
.history-entry.input {
@@ -93,6 +120,14 @@ const focusInput = () => {
color: #00ff88;
}
.history-entry.error {
color: #e94560;
}
.history-entry.processing {
color: #7b68ee;
}
.prompt-char {
color: #00d2ff;
}
@@ -101,6 +136,23 @@ const focusInput = () => {
color: #7b68ee;
}
.error-label {
color: #e94560;
}
.thinking {
font-style: italic;
}
.dots {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.cli-bar {
display: flex;
align-items: center;
@@ -129,6 +181,10 @@ const focusInput = () => {
caret-color: transparent;
}
.cli-input:disabled {
opacity: 0.5;
}
.cli-input::placeholder {
color: #8892b0;
opacity: 0.5;