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>
213 lines
4.9 KiB
Vue
213 lines
4.9 KiB
Vue
<script setup>
|
|
import { ref, nextTick, watch } from 'vue'
|
|
import axios from 'axios'
|
|
|
|
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 = async () => {
|
|
if (!input.value.trim() || isProcessing.value) return
|
|
|
|
const command = input.value.trim()
|
|
history.value.push({ type: 'input', text: command })
|
|
showHistory.value = true
|
|
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 = () => {
|
|
inputRef.value?.focus()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="cli-container" @click="focusInput">
|
|
<!-- History panel -->
|
|
<Transition name="slide-up">
|
|
<div v-if="showHistory && history.length > 0" class="cli-history">
|
|
<div
|
|
v-for="(entry, i) in history"
|
|
:key="i"
|
|
class="history-entry"
|
|
:class="entry.type"
|
|
>
|
|
<span v-if="entry.type === 'input'" class="prompt-char">> </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>
|
|
|
|
<!-- Input bar -->
|
|
<div class="cli-bar">
|
|
<span class="prompt">></span>
|
|
<input
|
|
ref="inputRef"
|
|
v-model="input"
|
|
class="cli-input"
|
|
:placeholder="isProcessing ? 'wachten op AI...' : 'stel een vraag...'"
|
|
:disabled="isProcessing"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
@keydown.enter="handleSubmit"
|
|
/>
|
|
<span class="cursor-blink">█</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.cli-container {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.cli-history {
|
|
background: rgba(22, 33, 62, 0.95);
|
|
border-top: 1px solid rgba(0, 210, 255, 0.2);
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
padding: 12px 20px;
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.history-entry {
|
|
font-family: 'VT323', monospace;
|
|
font-size: 16px;
|
|
line-height: 1.6;
|
|
color: #8892b0;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.history-entry.input {
|
|
color: #e8e8e8;
|
|
}
|
|
|
|
.history-entry.response {
|
|
color: #00ff88;
|
|
}
|
|
|
|
.history-entry.error {
|
|
color: #e94560;
|
|
}
|
|
|
|
.history-entry.processing {
|
|
color: #7b68ee;
|
|
}
|
|
|
|
.prompt-char {
|
|
color: #00d2ff;
|
|
}
|
|
|
|
.ai-label {
|
|
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;
|
|
background: #0a0a1a;
|
|
border-top: 2px solid #00d2ff;
|
|
padding: 12px 20px;
|
|
box-shadow: 0 -4px 30px rgba(0, 210, 255, 0.15);
|
|
}
|
|
|
|
.prompt {
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 12px;
|
|
color: #00d2ff;
|
|
margin-right: 12px;
|
|
text-shadow: 0 0 10px rgba(0, 210, 255, 0.5);
|
|
}
|
|
|
|
.cli-input {
|
|
flex: 1;
|
|
background: transparent;
|
|
border: none;
|
|
outline: none;
|
|
font-family: 'VT323', monospace;
|
|
font-size: 18px;
|
|
color: #e8e8e8;
|
|
caret-color: transparent;
|
|
}
|
|
|
|
.cli-input:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.cli-input::placeholder {
|
|
color: #8892b0;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.cursor-blink {
|
|
font-family: 'VT323', monospace;
|
|
font-size: 18px;
|
|
color: #00d2ff;
|
|
animation: blink 1s step-end infinite;
|
|
}
|
|
|
|
@keyframes blink {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0; }
|
|
}
|
|
|
|
.slide-up-enter-active, .slide-up-leave-active {
|
|
transition: all 0.25s ease;
|
|
}
|
|
.slide-up-enter-from, .slide-up-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(10px);
|
|
}
|
|
</style>
|