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;

View File

@@ -83,6 +83,7 @@ const handleClose = () => {
:model-value="form.eigenaar_id"
:options="userOptions"
:error="form.errors.eigenaar_id"
:required="true"
@update:model-value="form.eigenaar_id = $event"
/>

View File

@@ -1,10 +1,37 @@
<script setup>
defineProps({
import { computed } from 'vue'
const props = defineProps({
node: { type: Object, default: null },
visible: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'zoom-in'])
const childrenCount = computed(() => {
if (!props.node?.children) return 0
return props.node.children.nodes?.length ?? 0
})
const hasChildren = computed(() => childrenCount.value > 0 || !!props.node?.children)
/** Extract document summaries from children data (dim 2 document line) */
const documentNodes = computed(() => {
if (!props.node?.children?.nodes) return []
return props.node.children.nodes.filter(n => n.entityType === 'document')
})
/** Extract commitment summaries from children data */
const commitmentNodes = computed(() => {
if (!props.node?.children?.nodes) return []
return props.node.children.nodes.filter(n => n.entityType === 'commitment')
})
/** Extract phase nodes */
const phaseNodes = computed(() => {
if (!props.node?.children?.nodes) return []
return props.node.children.nodes.filter(n => n.entityType === 'fase')
})
</script>
<template>
@@ -20,15 +47,61 @@ const emit = defineEmits(['close', 'zoom-in'])
{{ node.status }}
</span>
<span v-if="node.owner" class="owner">{{ node.owner }}</span>
<span v-if="node.badge" class="badge-text">{{ node.badge }}</span>
</div>
<p v-if="node.description" class="preview-desc">{{ node.description }}</p>
<div v-if="node.children" class="preview-children">
<div class="children-label">Contains {{ node.children }} items</div>
<!-- Phase summary -->
<div v-if="phaseNodes.length" class="preview-section">
<h3 class="section-title">Levenscyclus</h3>
<div class="phase-track">
<span
v-for="phase in phaseNodes"
:key="phase.id"
class="phase-dot"
:class="phase.status"
:title="phase.name"
>{{ phase.name }}</span>
</div>
</div>
<button @click="emit('zoom-in', node)" class="zoom-btn">
<!-- Commitment summary -->
<div v-if="commitmentNodes.length" class="preview-section">
<h3 class="section-title">Commitments ({{ commitmentNodes.length }})</h3>
<div v-for="c in commitmentNodes.slice(0, 4)" :key="c.id" class="summary-item">
<span class="item-status" :class="c.status"></span>
<span class="item-text">{{ c.name }}</span>
<span v-if="c.badge" class="item-badge">{{ c.badge }}</span>
</div>
<div v-if="commitmentNodes.length > 4" class="more-hint">
+{{ commitmentNodes.length - 4 }} meer...
</div>
</div>
<!-- Document summary -->
<div v-if="documentNodes.length" class="preview-section">
<h3 class="section-title">Documenten ({{ documentNodes.length }})</h3>
<div v-for="d in documentNodes.slice(0, 4)" :key="d.id" class="summary-item">
<span class="item-icon">📄</span>
<span class="item-text">{{ d.name }}</span>
<span v-if="d.badge" class="item-badge">{{ d.badge }}</span>
</div>
<div v-if="documentNodes.length > 4" class="more-hint">
+{{ documentNodes.length - 4 }} meer...
</div>
</div>
<!-- Children indicator -->
<div v-if="hasChildren && !phaseNodes.length && !commitmentNodes.length && !documentNodes.length" class="preview-section">
<div class="children-label">Bevat {{ childrenCount }} items</div>
</div>
<div v-if="!hasChildren" class="preview-hint">
Rechts-klik "Add dimension" om dieper niveau toe te voegen
</div>
<button v-if="hasChildren" @click="emit('zoom-in', node)" class="zoom-btn">
ZOOM IN &gt;&gt;
</button>
</div>
@@ -38,21 +111,25 @@ const emit = defineEmits(['close', 'zoom-in'])
<style scoped>
.node-preview {
position: fixed;
right: 16px;
left: 16px;
top: 60px;
width: 320px;
background: #16213e;
width: 380px;
max-height: calc(100vh - 140px);
overflow-y: auto;
background: rgba(22, 33, 62, 0.95);
border: 1px solid rgba(0, 210, 255, 0.3);
border-radius: 6px;
padding: 20px;
z-index: 60;
box-shadow: 0 0 30px rgba(0, 210, 255, 0.1);
backdrop-filter: blur(8px);
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.preview-title {
@@ -60,6 +137,7 @@ const emit = defineEmits(['close', 'zoom-in'])
font-size: 22px;
color: #00d2ff;
margin: 0;
line-height: 1.2;
}
.close-btn {
@@ -69,6 +147,7 @@ const emit = defineEmits(['close', 'zoom-in'])
border: none;
cursor: pointer;
font-size: 16px;
flex-shrink: 0;
}
.close-btn:hover {
@@ -77,8 +156,9 @@ const emit = defineEmits(['close', 'zoom-in'])
.preview-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
margin-top: 10px;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
@@ -91,14 +171,15 @@ const emit = defineEmits(['close', 'zoom-in'])
letter-spacing: 0.5px;
}
.status-badge.actief, .status-badge.active { background: rgba(0, 210, 255, 0.15); color: #00d2ff; }
.status-badge.afgerond, .status-badge.completed { background: rgba(0, 255, 136, 0.15); color: #00ff88; }
.status-badge.pilot, .status-badge.actief, .status-badge.active { background: rgba(0, 210, 255, 0.15); color: #00d2ff; }
.status-badge.evaluatie, .status-badge.afgerond, .status-badge.completed { background: rgba(0, 255, 136, 0.15); color: #00ff88; }
.status-badge.verkenning, .status-badge.concept, .status-badge.experiment { background: rgba(123, 104, 238, 0.15); color: #7b68ee; }
.status-badge.geparkeerd { background: rgba(255, 217, 61, 0.15); color: #ffd93d; }
.status-badge.gestopt { background: rgba(233, 69, 96, 0.15); color: #e94560; }
.status-badge.overdracht_bouwen { background: rgba(0, 255, 136, 0.1); color: #6bcb77; }
.owner {
color: #8892b0;
}
.owner { color: #8892b0; }
.badge-text { color: #7b68ee; }
.preview-desc {
font-family: 'IBM Plex Mono', monospace;
@@ -108,8 +189,97 @@ const emit = defineEmits(['close', 'zoom-in'])
line-height: 1.5;
}
.preview-children {
margin-top: 12px;
/* Sections */
.preview-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(0, 210, 255, 0.1);
}
.section-title {
font-family: 'VT323', monospace;
font-size: 14px;
color: #00d2ff;
margin: 0 0 8px 0;
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* Phase track */
.phase-track {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.phase-dot {
font-family: 'VT323', monospace;
font-size: 11px;
padding: 2px 6px;
border-radius: 2px;
background: rgba(136, 146, 176, 0.1);
color: #8892b0;
border: 1px solid rgba(136, 146, 176, 0.2);
}
.phase-dot.afgerond, .phase-dot.completed {
background: rgba(0, 255, 136, 0.1);
color: #00ff88;
border-color: rgba(0, 255, 136, 0.3);
}
.phase-dot.actief, .phase-dot.active {
background: rgba(0, 210, 255, 0.15);
color: #00d2ff;
border-color: rgba(0, 210, 255, 0.4);
box-shadow: 0 0 6px rgba(0, 210, 255, 0.2);
}
/* Summary items */
.summary-item {
display: flex;
align-items: baseline;
gap: 6px;
padding: 3px 0;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #8892b0;
}
.item-status {
font-size: 8px;
flex-shrink: 0;
}
.item-status.open, .item-status.in_uitvoering { color: #00d2ff; }
.item-status.afgerond { color: #00ff88; }
.item-status.verlopen { color: #e94560; }
.item-icon {
font-size: 10px;
flex-shrink: 0;
}
.item-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-badge {
font-family: 'VT323', monospace;
font-size: 11px;
color: #7b68ee;
flex-shrink: 0;
}
.more-hint {
font-family: 'VT323', monospace;
font-size: 12px;
color: #8892b0;
opacity: 0.6;
padding-top: 4px;
}
.children-label {
@@ -118,10 +288,18 @@ const emit = defineEmits(['close', 'zoom-in'])
color: #7b68ee;
}
.preview-hint {
margin-top: 12px;
font-family: 'VT323', monospace;
font-size: 12px;
color: #8892b0;
opacity: 0.5;
}
.zoom-btn {
margin-top: 16px;
width: 100%;
padding: 8px;
padding: 10px;
background: rgba(0, 210, 255, 0.1);
border: 1px solid #00d2ff;
color: #00d2ff;
@@ -130,11 +308,25 @@ const emit = defineEmits(['close', 'zoom-in'])
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
letter-spacing: 0.1em;
}
.zoom-btn:hover {
background: rgba(0, 210, 255, 0.2);
box-shadow: 0 0 15px rgba(0, 210, 255, 0.3);
text-shadow: 0 0 8px rgba(0, 210, 255, 0.5);
}
/* Scrollbar styling */
.node-preview::-webkit-scrollbar {
width: 4px;
}
.node-preview::-webkit-scrollbar-track {
background: transparent;
}
.node-preview::-webkit-scrollbar-thumb {
background: rgba(0, 210, 255, 0.2);
border-radius: 2px;
}
.slide-enter-active, .slide-leave-active {
@@ -142,6 +334,6 @@ const emit = defineEmits(['close', 'zoom-in'])
}
.slide-enter-from, .slide-leave-to {
opacity: 0;
transform: translateX(20px);
transform: translateX(-20px);
}
</style>

View File

@@ -157,12 +157,42 @@ const submitTrackForm = () => {
})
}
// --- Metro node creation (for custom line extend) ---
// --- Metro node creation form (for custom line extend) ---
const showMetroNodeForm = ref(false)
const pendingMetroNodeEvent = ref(null)
const metroNodeForm = useForm({
metro_line_id: '',
naam: '',
beschrijving: '',
x: 0,
y: 0,
})
const createMetroNode = (event) => {
// Determine metro_line_id from the lineId
// lineId format for custom lines will need to be mapped
// For now, emit a placeholder
console.log('Create metro node on custom line:', event)
pendingMetroNodeEvent.value = event
// Extract metro_line_id from lineId (format: "custom-{id}" or similar)
metroNodeForm.x = event.x
metroNodeForm.y = event.y
metroNodeForm.naam = ''
metroNodeForm.beschrijving = ''
showMetroNodeForm.value = true
}
const submitMetroNodeForm = () => {
const event = pendingMetroNodeEvent.value
// The lineId from the canvas might be something like "custom-5" — extract the DB id
// For now, try to find the metro_line by matching
const lineId = event?.lineId ?? ''
const match = lineId.match(/\d+$/)
metroNodeForm.metro_line_id = match ? match[0] : ''
metroNodeForm.post('/metro-nodes', {
onSuccess: () => {
showMetroNodeForm.value = false
metroNodeForm.reset()
pendingMetroNodeEvent.value = null
},
})
}
// --- FAB handlers ---
@@ -188,10 +218,6 @@ const handleFabItemLeave = () => {
canvasRef.value?.setHighlightedLine(null)
}
const handleCliCommand = (command) => {
console.log('CLI command:', command)
}
const logout = () => {
router.post('/logout')
}
@@ -300,7 +326,46 @@ const editingProject = ref(null)
</Transition>
</Teleport>
<CliBar @command="handleCliCommand" />
<!-- Metro node creation modal (for custom line extend) -->
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="showMetroNodeForm" class="modal-backdrop" @click="showMetroNodeForm = false">
<div class="modal-content" @click.stop>
<div class="modal-header">NIEUW PUNT</div>
<form @submit.prevent="submitMetroNodeForm">
<div class="form-group">
<label class="form-label">Naam</label>
<input
v-model="metroNodeForm.naam"
type="text"
class="form-input"
placeholder="Naam van het punt..."
required
autofocus
/>
</div>
<div class="form-group">
<label class="form-label">Beschrijving</label>
<textarea
v-model="metroNodeForm.beschrijving"
class="form-input form-textarea"
placeholder="Optionele beschrijving..."
rows="3"
></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn-cancel" @click="showMetroNodeForm = false">Annuleren</button>
<button type="submit" class="btn-submit" :disabled="metroNodeForm.processing">
{{ metroNodeForm.processing ? 'Bezig...' : 'Aanmaken' }}
</button>
</div>
</form>
</div>
</div>
</Transition>
</Teleport>
<CliBar :project-id="currentProjectId" />
</div>
</template>
@@ -434,6 +499,11 @@ const editingProject = ref(null)
box-shadow: 0 0 8px rgba(0, 210, 255, 0.2);
}
.form-textarea {
resize: vertical;
min-height: 60px;
}
.form-actions {
display: flex;
justify-content: flex-end;