refactor(private): extraire composants, constantes et helpers

- frontend/src/components/private/Step2PrivateDecision.vue
- frontend/src/components/private/Step3PrivateSim.vue
- frontend/src/components/private/Step4PrivateReport.vue
- frontend/src/components/private/Step5PrivateInteraction.vue
- frontend/src/constants/private.js (RELATIONAL_TYPE_LABELS, etc.)
- frontend/src/utils/private.js (buildRequirement, helpers purs)

Prompt N°23 — Roadmap refactoring wizard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cyril 2026-04-17 21:10:41 +02:00
parent 074c03d685
commit e4fe3f91aa
6 changed files with 1491 additions and 0 deletions

View File

@ -0,0 +1,418 @@
<template>
<div class="form-container">
<div class="section-title-row">
<h2 class="section-h2">Define the Decision</h2>
<p class="section-hint">Fill in the decision context. These details will drive the simulation.</p>
</div>
<div
class="drop-zone"
:class="{ 'drop-zone--active': isDragOver }"
@dragover.prevent="isDragOver = true"
@dragleave="isDragOver = false"
@drop.prevent="handleDrop"
@click="triggerImport"
>
<input type="file" ref="importInput" accept=".txt" style="display:none" @change="handleImport" />
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span class="drop-zone-label">Glisser le fichier ici ou cliquer pour importer</span>
<span class="drop-zone-hint">private_impact_requirement.txt dossier 02_simulation_params/</span>
</div>
<div v-if="projectId && !projectData?.graph_id" class="graph-building-notice">
<div class="loading-ring loading-ring--sm"></div>
<span>Graphe en construction les champs peuvent déjà être remplis. Le bouton s'activera automatiquement.</span>
</div>
<div class="form-grid">
<!-- Left column -->
<div class="form-col">
<div class="field-group">
<label class="field-label">DECISION MAKER</label>
<div class="field-row-3">
<input class="field-input" v-model="form.decisionMakerName" placeholder="Full name" />
<input class="field-input" v-model="form.decisionMakerRole" placeholder="Role / title" />
<input class="field-input" v-model="form.decisionMakerCompany" placeholder="Organisation" />
</div>
</div>
<div class="field-group">
<label class="field-label">DECISION <span class="required">*</span></label>
<textarea
class="field-textarea"
v-model="form.decisionText"
rows="5"
placeholder="Describe the decision precisely. E.g. 'We are closing the Lyon office and transferring 40 employees to Paris by Q3.'"
></textarea>
</div>
<div class="field-group">
<label class="field-label">ADDITIONAL CONTEXT</label>
<textarea
class="field-textarea"
v-model="form.decisionContext"
rows="3"
placeholder="Background information, strategic rationale, known sensitivities..."
></textarea>
</div>
</div>
<!-- Right column -->
<div class="form-col">
<div class="field-group">
<label class="field-label">RELATIONAL NETWORK types to include</label>
<div class="checkbox-grid">
<label
v-for="t in RELATIONAL_TYPES"
:key="t"
class="checkbox-item"
:class="{ 'is-checked': form.relationalTypes.includes(t) }"
>
<input
type="checkbox"
:value="t"
v-model="form.relationalTypes"
class="checkbox-native"
/>
<span class="checkbox-box"></span>
<span class="checkbox-label">{{ RELATIONAL_TYPE_LABELS[t] }}</span>
</label>
</div>
<div v-if="form.relationalTypes.length > 0" class="agent-counts-block">
<div v-for="t in form.relationalTypes" :key="t" class="agent-count-row">
<span class="agent-count-label">{{ RELATIONAL_TYPE_LABELS[t] }}</span>
<div class="agent-count-sep"></div>
<input
type="number"
class="agent-count-input"
v-model.number="agentCounts[t]"
min="1"
max="200"
/>
</div>
<div class="agent-count-total">Total : {{ totalAgents }} agents</div>
</div>
</div>
<div class="field-group">
<label class="field-label">TEMPORAL HORIZON</label>
<div class="horizon-btns">
<button
v-for="opt in HORIZON_OPTIONS"
:key="opt.days"
type="button"
class="horizon-btn"
:class="{ 'is-active': form.horizonDays === opt.days }"
@click="form.horizonDays = opt.days"
>{{ opt.label }}</button>
</div>
</div>
<div class="field-group">
<label class="field-label">QUESTIONS TO MEASURE</label>
<textarea
class="field-textarea"
v-model="form.questionsToMeasure"
rows="3"
placeholder="What do you want to measure? E.g. 'What is the risk of collective resistance? Who are the key opinion leaders?'"
></textarea>
</div>
</div>
</div>
<div class="form-footer">
<button
class="btn-primary"
:disabled="!form.decisionText.trim() || form.relationalTypes.length === 0 || (projectId && !projectData?.graph_id)"
@click="$emit('prepare')"
>
Prepare Simulation
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" />
</svg>
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { RELATIONAL_TYPES, RELATIONAL_TYPE_LABELS, HORIZON_OPTIONS } from '../../constants/private.js'
import { parseImportedConfig } from '../../utils/private.js'
const props = defineProps({
form: { type: Object, required: true },
agentCounts: { type: Object, required: true },
projectId: { type: String, default: null },
projectData: { type: Object, default: null },
})
defineEmits(['prepare'])
const importInput = ref(null)
const isDragOver = ref(false)
const totalAgents = computed(() =>
Object.values(props.agentCounts).reduce((sum, n) => sum + (n || 0), 0)
)
watch(() => props.form.relationalTypes, (types) => {
for (const t of types) {
if (!(t in props.agentCounts)) props.agentCounts[t] = 10
}
for (const key of Object.keys(props.agentCounts)) {
if (!types.includes(key)) delete props.agentCounts[key]
}
}, { immediate: true, deep: true })
const triggerImport = () => { importInput.value?.click() }
const handleDrop = (event) => {
isDragOver.value = false
const file = event.dataTransfer.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => parseImportedConfig(e.target.result, props.form, props.agentCounts, RELATIONAL_TYPES, RELATIONAL_TYPE_LABELS)
reader.readAsText(file)
}
const handleImport = (event) => {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
parseImportedConfig(e.target.result, props.form, props.agentCounts, RELATIONAL_TYPES, RELATIONAL_TYPE_LABELS)
event.target.value = ''
}
reader.readAsText(file)
}
</script>
<style scoped>
.form-container { max-width: 1100px; margin: 0 auto; }
.section-title-row { margin-bottom: 24px; }
.section-h2 { font-size: 18px; font-weight: 700; color: #000; margin-bottom: 6px; }
.section-hint { font-size: 13px; color: #777; }
.graph-building-notice {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #FFF8E1;
border: 1px solid #FFE082;
border-radius: 4px;
font-size: 12px;
color: #795548;
margin-bottom: 20px;
}
.loading-ring {
width: 40px;
height: 40px;
border: 3px solid #E5E7EB;
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-ring--sm {
width: 16px;
height: 16px;
border-width: 2px;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.drop-zone {
border: 2px dashed #D0D0D0;
border-radius: 6px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 24px;
color: #AAA;
}
.drop-zone:hover { border-color: #000; color: #000; }
.drop-zone--active { border-color: #000; background: #F5F5F5; color: #000; }
.drop-zone-label { font-size: 13px; font-weight: 600; }
.drop-zone-hint { font-size: 10px; letter-spacing: 0.04em; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
.form-col { display: flex; flex-direction: column; gap: 20px; }
.field-group { display: flex; flex-direction: column; gap: 8px; }
.field-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
color: #888;
}
.required { color: #FF5722; }
.field-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
.field-input, .field-textarea {
border: 1.5px solid #E0E0E0;
border-radius: 3px;
padding: 9px 12px;
font-size: 13px;
font-family: inherit;
color: #000;
background: #fff;
transition: border-color 0.15s;
resize: vertical;
}
.field-input:focus, .field-textarea:focus {
outline: none;
border-color: #000;
}
.field-input::placeholder, .field-textarea::placeholder { color: #BBB; }
.checkbox-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 6px 8px;
border: 1.5px solid #E8E8E8;
border-radius: 3px;
transition: border-color 0.15s, background 0.15s;
}
.checkbox-item.is-checked { border-color: #000; background: #FAFAFA; }
.checkbox-native { display: none; }
.checkbox-box {
width: 14px;
height: 14px;
border: 1.5px solid #CCC;
border-radius: 2px;
flex-shrink: 0;
background: #fff;
transition: all 0.12s;
}
.checkbox-item.is-checked .checkbox-box {
background: #000;
border-color: #000;
}
.checkbox-label { font-size: 11px; font-weight: 500; color: #444; text-transform: capitalize; }
.form-footer { margin-top: 28px; display: flex; justify-content: flex-end; }
.btn-primary {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 22px;
background: #000;
color: #fff;
border: none;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover { background: #222; }
.btn-primary:disabled { background: #CCC; cursor: not-allowed; }
.horizon-btns { display: flex; flex-wrap: wrap; gap: 8px; }
.horizon-btn {
padding: 7px 14px;
border: 1.5px solid #E8E8E8;
border-radius: 3px;
background: #fff;
font-size: 12px;
font-weight: 500;
font-family: inherit;
color: #444;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.horizon-btn.is-active {
border-color: #000;
background: #FAFAFA;
color: #000;
font-weight: 600;
}
.agent-counts-block {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.agent-count-row {
display: flex;
align-items: center;
gap: 8px;
}
.agent-count-label {
min-width: 130px;
font-size: 11px;
font-weight: 500;
color: #444;
flex-shrink: 0;
}
.agent-count-sep {
flex: 1;
height: 1px;
background: #E8E8E8;
}
.agent-count-input {
width: 64px;
border: 1.5px solid #E0E0E0;
border-radius: 3px;
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
color: #000;
text-align: right;
background: #fff;
flex-shrink: 0;
}
.agent-count-input:focus { outline: none; border-color: #000; }
.agent-count-total {
font-size: 11px;
font-weight: 700;
color: #555;
letter-spacing: 0.04em;
text-align: right;
margin-top: 4px;
}
</style>

View File

@ -0,0 +1,460 @@
<template>
<div class="run-layout">
<!-- Left: Progress panel -->
<div class="run-progress-panel">
<div class="run-platform-status" :class="{ 'is-running': simStatus?.private_running, 'is-done': simStatus?.private_completed }">
<div class="rps-header">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>Private Network</span>
<span v-if="simStatus?.private_completed" class="rps-badge-done">DONE</span>
<span v-else-if="simStatus?.private_running" class="rps-badge-run">RUNNING</span>
<span v-else class="rps-badge-idle">IDLE</span>
</div>
<div class="rps-stats">
<div class="rps-stat">
<span class="rps-stat-label">ROUND</span>
<span class="rps-stat-value mono">{{ simStatus?.private_current_round || 0 }}</span>
</div>
<div class="rps-stat">
<span class="rps-stat-label">DAY</span>
<span class="rps-stat-value mono">{{ simStatus?.private_simulated_days || 0 }}</span>
</div>
<div class="rps-stat">
<span class="rps-stat-label">ACTIONS</span>
<span class="rps-stat-value mono">{{ simStatus?.private_actions_count || 0 }}</span>
</div>
</div>
<div class="rps-progress-track">
<div
class="rps-progress-fill"
:style="{ width: roundProgress + '%' }"
></div>
</div>
<div class="rps-progress-label mono">{{ roundProgress }}%</div>
</div>
<div class="run-action-types">
<div class="run-action-types-title">ACTION TYPES</div>
<div v-for="(count, type) in actionTypeCounts" :key="type" class="action-type-row">
<span class="action-type-name">{{ type }}</span>
<span class="action-type-count mono">{{ count }}</span>
</div>
<div v-if="Object.keys(actionTypeCounts).length === 0" class="no-actions-yet">
Waiting for first actions
</div>
</div>
<div class="run-controls">
<button
v-if="simStatus?.runner_status === 'running'"
class="btn-stop"
@click="$emit('stop')"
>
Stop Simulation
</button>
<button
v-if="simStatus?.runner_status === 'completed' || simStatus?.runner_status === 'stopped'"
class="btn-primary"
@click="$emit('report')"
>
Generate Report
</button>
</div>
</div>
<!-- Right col: propagation graph + reduced feed -->
<div class="run-right-col">
<div class="graph-panel" ref="graphContainer"></div>
<div class="run-feed-panel" ref="feedPanel">
<div class="feed-header">LIVE ACTION FEED</div>
<div class="feed-list">
<div
v-for="(action, idx) in recentActions.slice(-10)"
:key="idx"
class="feed-item"
>
<span class="feed-round mono">#{{ action.round_num }}</span>
<span class="feed-agent">{{ action.agent_name || `Agent ${action.agent_id}` }}</span>
<span class="feed-action-type" :class="actionTypeClass(action.action_type)">{{ action.action_type }}</span>
<span class="feed-time mono">{{ shortTime(action.timestamp) }}</span>
</div>
<div v-if="recentActions.length === 0" class="feed-empty">Waiting for simulation events</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import * as d3 from 'd3'
import { ACTION_COLORS } from '../../constants/private.js'
import { shortTime, actionTypeClass, nodeColor } from '../../utils/private.js'
const props = defineProps({
simStatus: { type: Object, default: null },
recentActions: { type: Array, default: () => [] },
})
defineEmits(['stop', 'report'])
const graphContainer = ref(null)
const feedPanel = ref(null)
let simulation = null
let svgEl = null
let linkGroup = null
let nodeGroup = null
const roundProgress = computed(() => {
if (props.simStatus?.progress_percent != null) return props.simStatus.progress_percent
const total = props.simStatus?.private_total_rounds || 0
const current = props.simStatus?.private_current_round || 0
if (!total) return 0
return Math.round((current / total) * 100)
})
const actionTypeCounts = computed(() => {
const counts = {}
for (const action of props.recentActions) {
counts[action.action_type] = (counts[action.action_type] || 0) + 1
}
return counts
})
const ticked = () => {
if (!linkGroup || !nodeGroup) return
linkGroup.selectAll('line')
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)
nodeGroup.selectAll('g.node')
.attr('transform', d => `translate(${d.x},${d.y})`)
}
const initGraph = () => {
if (!graphContainer.value) return
const container = graphContainer.value
const width = container.clientWidth || 600
const height = container.clientHeight || 400
d3.select(container).selectAll('*').remove()
svgEl = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${width} ${height}`)
linkGroup = svgEl.append('g').attr('class', 'links')
nodeGroup = svgEl.append('g').attr('class', 'nodes')
simulation = d3.forceSimulation([])
.force('charge', d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('link', d3.forceLink([]).id(d => d.id).distance(80))
.on('tick', ticked)
}
const updateGraph = (actions) => {
if (!simulation || !svgEl) return
const agentActions = {}
const agentNames = {}
const linkPairs = {}
const staticAgents = props.simStatus?.agents || []
for (const a of staticAgents) {
if (a.agent_id === undefined || a.agent_id === null) continue
const sid = String(a.agent_id)
agentNames[sid] = a.entity_name || `Agent ${sid}`
}
for (const action of actions) {
const id = action.agent_id
if (id === undefined || id === null) continue
const sid = String(id)
agentNames[sid] = action.agent_name || agentNames[sid] || `Agent ${sid}`
if (!agentActions[sid]) agentActions[sid] = {}
const t = action.action_type || 'DO_NOTHING'
agentActions[sid][t] = (agentActions[sid][t] || 0) + 1
if (action.target_agent_id !== undefined && action.target_agent_id !== null) {
const key = `${sid}__${String(action.target_agent_id)}`
linkPairs[key] = (linkPairs[key] || 0) + 1
}
}
const nodes = Object.keys(agentNames).map(id => {
const counts = agentActions[id] || {}
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'DO_NOTHING'
return { id, name: agentNames[id], dominant }
})
const nodeIds = new Set(nodes.map(n => n.id))
const staticEdges = props.simStatus?.relational_edges || []
const staticKeys = new Set()
for (const e of staticEdges) {
if (e.source === undefined || e.target === undefined) continue
staticKeys.add(`${String(e.source)}__${String(e.target)}`)
}
const links = []
staticKeys.forEach(key => {
const [source, target] = key.split('__')
if (nodeIds.has(source) && nodeIds.has(target)) {
links.push({ source, target, count: linkPairs[key] || 0, kind: 'cascade' })
}
})
Object.entries(linkPairs).forEach(([key, count]) => {
if (staticKeys.has(key)) return
const [source, target] = key.split('__')
if (nodeIds.has(source) && nodeIds.has(target)) {
links.push({ source, target, count, kind: 'action' })
}
})
const existing = {}
simulation.nodes().forEach(n => { existing[n.id] = { x: n.x, y: n.y, vx: n.vx, vy: n.vy } })
nodes.forEach(n => {
if (existing[n.id]) {
n.x = existing[n.id].x; n.y = existing[n.id].y
n.vx = existing[n.id].vx; n.vy = existing[n.id].vy
}
})
simulation.nodes(nodes)
simulation.force('link').links(links)
simulation.alpha(0.3).restart()
const linkSel = linkGroup.selectAll('line')
.data(links, d => `${d.source.id || d.source}__${d.target.id || d.target}`)
linkSel.exit().remove()
linkSel.enter().append('line')
.merge(linkSel)
.attr('stroke', d => d.kind === 'cascade' && d.count === 0 ? '#E5E5E5' : '#999')
.attr('stroke-dasharray', d => d.kind === 'cascade' && d.count === 0 ? '3,3' : null)
.attr('stroke-width', d => Math.min(1 + d.count * 0.5, 4))
const nodeSel = nodeGroup.selectAll('g.node').data(nodes, d => d.id)
nodeSel.exit().remove()
const nodeEnter = nodeSel.enter().append('g').attr('class', 'node')
nodeEnter.append('circle').attr('r', 8)
nodeEnter.append('text')
.attr('y', 20)
.attr('text-anchor', 'middle')
.attr('font-size', '9px')
.attr('fill', '#555')
const nodeMerge = nodeEnter.merge(nodeSel)
nodeMerge.select('circle')
.attr('fill', d => nodeColor(d.dominant, ACTION_COLORS))
.attr('stroke', '#fff')
.attr('stroke-width', 1.5)
nodeMerge.select('text')
.text(d => d.name.slice(0, 12))
}
onMounted(async () => {
await nextTick()
initGraph()
updateGraph(props.recentActions || [])
})
onUnmounted(() => {
if (simulation) simulation.stop()
})
watch(() => props.recentActions.length, () => {
nextTick(() => {
if (feedPanel.value) feedPanel.value.scrollTop = feedPanel.value.scrollHeight
})
updateGraph(props.recentActions)
})
watch(
() => (props.simStatus?.agents?.length || 0) + (props.simStatus?.relational_edges?.length || 0),
() => updateGraph(props.recentActions || [])
)
</script>
<style scoped>
.run-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 20px;
height: calc(100vh - 172px);
}
.run-progress-panel {
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.run-platform-status {
border: 1.5px solid #E0E0E0;
border-radius: 4px;
padding: 16px;
transition: border-color 0.2s;
}
.run-platform-status.is-running { border-color: #FF5722; }
.run-platform-status.is-done { border-color: #4CAF50; }
.rps-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 700;
color: #000;
margin-bottom: 12px;
}
.rps-badge-run { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: #FF5722; background: #FFF3E0; padding: 2px 6px; border-radius: 2px; }
.rps-badge-done { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: #2E7D32; background: #E8F5E9; padding: 2px 6px; border-radius: 2px; }
.rps-badge-idle { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: #999; background: #F5F5F5; padding: 2px 6px; border-radius: 2px; }
.rps-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
.rps-stat { display: flex; flex-direction: column; gap: 2px; }
.rps-stat-label { font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: #AAA; }
.rps-stat-value { font-size: 18px; font-weight: 700; color: #000; }
.rps-progress-track { height: 4px; background: #E8E8E8; border-radius: 2px; overflow: hidden; margin-bottom: 4px; }
.rps-progress-fill { height: 100%; background: #000; border-radius: 2px; transition: width 0.5s ease; }
.rps-progress-label { font-size: 10px; color: #888; text-align: right; }
.run-action-types {
border: 1.5px solid #EFEFEF;
border-radius: 4px;
padding: 12px;
}
.run-action-types-title { font-size: 9px; font-weight: 700; letter-spacing: 0.12em; color: #AAA; margin-bottom: 8px; }
.action-type-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid #F5F5F5;
font-size: 11px;
}
.action-type-name { color: #555; text-transform: uppercase; font-size: 10px; font-weight: 600; }
.action-type-count { color: #000; font-weight: 700; }
.no-actions-yet { font-size: 11px; color: #CCC; }
.run-controls { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
.btn-primary {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 22px;
background: #000;
color: #fff;
border: none;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover { background: #222; }
.btn-primary:disabled { background: #CCC; cursor: not-allowed; }
.btn-stop {
padding: 9px 18px;
background: #fff;
color: #C62828;
border: 1.5px solid #C62828;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.run-right-col {
display: flex;
flex-direction: column;
gap: 12px;
height: calc(100vh - 172px);
}
.graph-panel {
flex: 1;
border: 1.5px solid #EFEFEF;
border-radius: 4px;
overflow: hidden;
background: #FAFAFA;
}
.run-feed-panel {
border: 1.5px solid #EFEFEF;
border-radius: 4px;
overflow-y: auto;
display: flex;
flex-direction: column;
height: 200px;
flex-shrink: 0;
}
.feed-header {
padding: 10px 14px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.14em;
color: #AAA;
border-bottom: 1px solid #F0F0F0;
background: #FAFAFA;
position: sticky;
top: 0;
flex-shrink: 0;
}
.feed-list { flex: 1; padding: 8px 0; }
.feed-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
border-bottom: 1px solid #F7F7F7;
font-size: 11px;
}
.feed-round { color: #BBB; min-width: 36px; flex-shrink: 0; }
.feed-agent { color: #333; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-time { color: #CCC; flex-shrink: 0; font-size: 10px; }
.feed-action-type {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 2px 6px;
border-radius: 2px;
flex-shrink: 0;
}
.type-hostile { background: #FFEBEE; color: #C62828; }
.type-support { background: #E8F5E9; color: #2E7D32; }
.type-passive { background: #F5F5F5; color: #999; }
.type-neutral { background: #E3F2FD; color: #1565C0; }
.feed-empty { padding: 24px 14px; font-size: 12px; color: #CCC; }
.mono { font-family: 'JetBrains Mono', monospace; }
</style>

View File

@ -0,0 +1,169 @@
<template>
<div class="centered-panel">
<!-- Generating -->
<div v-if="isLoading" class="loading-block">
<div class="loading-ring"></div>
<p class="loading-label">Report Agent is analysing the simulation</p>
<p class="loading-hint">{{ reportProgress }}</p>
</div>
<!-- Report ready -->
<div v-else-if="reportResult" class="report-ready">
<div class="result-badge result-badge--ok">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12" />
</svg>
Report ready
</div>
<div class="report-sections" v-if="reportResult.outline?.sections?.length">
<div v-for="(section, idx) in reportResult.outline.sections" :key="idx" class="report-section">
<div class="rs-header" @click="toggleSection(idx)">
<span class="rs-num">{{ String(idx + 1).padStart(2, '0') }}</span>
<span class="rs-title">{{ section.title || ('Section ' + String(idx + 1).padStart(2, '0')) }}</span>
<svg class="rs-chevron" :class="{ 'is-open': !collapsedSections.has(idx) }" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
<div v-show="!collapsedSections.has(idx)" class="rs-body">
<p>{{ section.content }}</p>
</div>
</div>
</div>
<pre v-else-if="reportResult.markdown_content" class="report-markdown">{{ reportResult.markdown_content }}</pre>
<div class="result-actions">
<button class="btn-secondary" @click="onExport">
Export .md
</button>
<button class="btn-secondary" @click="$emit('next')">
Talk to Agents
</button>
</div>
</div>
<!-- Error generating report -->
<div v-else class="error-placeholder">
<p>Report generation did not complete. Check logs and retry.</p>
<button class="btn-secondary" @click="$emit('retry')">Retry</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { exportReportMarkdown } from '../../utils/private.js'
const props = defineProps({
reportResult: { type: Object, default: null },
isLoading: { type: Boolean, default: false },
reportProgress: { type: String, default: '' },
simId: { type: String, default: null },
})
defineEmits(['retry', 'next'])
const collapsedSections = ref(new Set())
const toggleSection = (idx) => {
const s = new Set(collapsedSections.value)
s.has(idx) ? s.delete(idx) : s.add(idx)
collapsedSections.value = s
}
const onExport = () => {
exportReportMarkdown(props.reportResult, props.simId)
}
</script>
<style scoped>
.centered-panel {
max-width: 680px;
margin: 0 auto;
}
.loading-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 60px 0;
text-align: center;
}
.loading-ring {
width: 40px;
height: 40px;
border: 3px solid #E5E7EB;
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-label { font-size: 14px; font-weight: 600; color: #000; }
.loading-hint { font-size: 12px; color: #888; max-width: 400px; line-height: 1.5; }
.result-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
padding: 6px 12px;
border-radius: 2px;
}
.result-badge--ok { background: #E8F5E9; color: #2E7D32; }
.result-actions { display: flex; gap: 10px; }
.btn-secondary {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
background: #fff;
color: #000;
border: 1.5px solid #000;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary:hover { background: #F5F5F5; }
.report-ready { display: flex; flex-direction: column; gap: 20px; padding: 20px 0; }
.report-markdown { white-space: pre-wrap; font-size: 13px; line-height: 1.7; color: #222; font-family: inherit; background: #FAFAFA; border: 1.5px solid #E8E8E8; border-radius: 4px; padding: 20px; margin: 0; }
.report-sections { display: flex; flex-direction: column; gap: 0; border: 1.5px solid #E8E8E8; border-radius: 4px; overflow: hidden; }
.report-section { border-bottom: 1px solid #F0F0F0; }
.report-section:last-child { border-bottom: none; }
.rs-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
cursor: pointer;
background: #FAFAFA;
transition: background 0.12s;
}
.rs-header:hover { background: #F3F3F3; }
.rs-num { font-size: 11px; font-weight: 700; color: #CCC; min-width: 24px; }
.rs-title { flex: 1; font-size: 13px; font-weight: 600; color: #000; }
.rs-chevron { flex-shrink: 0; transition: transform 0.2s; transform: rotate(-90deg); }
.rs-chevron.is-open { transform: rotate(0deg); }
.rs-body { padding: 14px 16px 14px 52px; font-size: 13px; color: #444; line-height: 1.6; background: #fff; }
.error-placeholder { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 40px 0; font-size: 13px; color: #888; }
</style>

View File

@ -0,0 +1,290 @@
<template>
<div class="chat-layout">
<!-- Left: agent list -->
<div class="chat-agents-panel">
<div class="chat-agents-title">RELATIONAL AGENTS</div>
<div
v-for="agent in chatAgents"
:key="agent.agent_id"
class="chat-agent-item"
:class="{ 'is-selected': selectedAgentId === agent.agent_id }"
@click="selectedAgentId = agent.agent_id"
>
<div class="agent-avatar">{{ initials(agent.entity_name) }}</div>
<div class="agent-info">
<div class="agent-name">{{ agent.entity_name }}</div>
<div class="agent-type">{{ agent.relational_link_type }}</div>
</div>
<div class="agent-stance-dot" :class="'stance-' + agent.stance"></div>
</div>
<div v-if="chatAgents.length === 0" class="chat-agents-empty">Loading agents</div>
</div>
<!-- Right: chat -->
<div class="chat-main">
<div class="chat-messages" ref="chatMessagesEl">
<div v-if="!selectedAgentId" class="chat-placeholder">
Select an agent on the left to start a conversation.
</div>
<template v-else>
<div
v-for="(msg, idx) in currentMessages"
:key="idx"
class="chat-msg"
:class="msg.role === 'user' ? 'chat-msg--user' : 'chat-msg--agent'"
>
<div class="chat-msg-label">{{ msg.role === 'user' ? 'You' : selectedAgentName }}</div>
<div class="chat-msg-text">{{ msg.content }}</div>
</div>
<div v-if="isChatLoading" class="chat-msg chat-msg--agent">
<div class="chat-msg-label">{{ selectedAgentName }}</div>
<div class="chat-msg-text chat-thinking">
<span></span><span></span><span></span>
</div>
</div>
</template>
</div>
<div class="chat-input-row" v-if="selectedAgentId">
<textarea
class="chat-input"
v-model="chatInput"
placeholder="Ask this agent a question…"
rows="2"
@keydown.enter.exact.prevent="sendChat"
></textarea>
<button class="chat-send-btn" :disabled="!chatInput.trim() || isChatLoading" @click="sendChat">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, nextTick } from 'vue'
import { interviewAgents } from '../../api/simulation.js'
import { initials } from '../../utils/private.js'
const props = defineProps({
simId: { type: String, required: true },
chatAgents: { type: Array, required: true },
})
const selectedAgentId = ref(null)
const chatMessages = reactive({})
const chatInput = ref('')
const isChatLoading = ref(false)
const chatMessagesEl = ref(null)
const selectedAgentName = computed(() => {
const agent = props.chatAgents.find(a => a.agent_id === selectedAgentId.value)
return agent?.entity_name || `Agent ${selectedAgentId.value}`
})
const currentMessages = computed(() => {
return chatMessages[selectedAgentId.value] || []
})
const scrollChat = () => {
if (chatMessagesEl.value) {
chatMessagesEl.value.scrollTop = chatMessagesEl.value.scrollHeight
}
}
const sendChat = async () => {
if (!chatInput.value.trim() || !selectedAgentId.value || isChatLoading.value) return
const userMsg = chatInput.value.trim()
chatInput.value = ''
if (!chatMessages[selectedAgentId.value]) chatMessages[selectedAgentId.value] = []
chatMessages[selectedAgentId.value].push({ role: 'user', content: userMsg })
await nextTick()
scrollChat()
isChatLoading.value = true
try {
const history = chatMessages[selectedAgentId.value]
.slice(0, -1)
.map(m => ({ role: m.role, content: m.content }))
const historyContext = history
.map(m => `${m.role === 'user' ? 'User' : 'You'}: ${m.content}`)
.join('\n')
const prompt = historyContext
? `Previous conversation:\n${historyContext}\n\nNew question: ${userMsg}`
: userMsg
const res = await interviewAgents({
simulation_id: props.simId,
interviews: [{
agent_id: selectedAgentId.value,
prompt,
}],
})
let reply = '(no response)'
if (res.success && res.data) {
const resultData = res.data.result || res.data
const resultsDict = resultData.results || resultData
const first = Object.values(resultsDict || {}).find(v => v && v.response)
if (first?.response) reply = first.response
}
chatMessages[selectedAgentId.value].push({ role: 'agent', content: reply })
} catch (e) {
chatMessages[selectedAgentId.value].push({ role: 'agent', content: `Error: ${e.message}` })
} finally {
isChatLoading.value = false
await nextTick()
scrollChat()
}
}
</script>
<style scoped>
.chat-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 16px;
height: calc(100vh - 172px);
}
.chat-agents-panel { border: 1.5px solid #EFEFEF; border-radius: 4px; overflow-y: auto; }
.chat-agents-title { padding: 10px 14px; font-size: 9px; font-weight: 700; letter-spacing: 0.14em; color: #AAA; border-bottom: 1px solid #F0F0F0; background: #FAFAFA; }
.chat-agent-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid #F5F5F5;
cursor: pointer;
transition: background 0.12s;
}
.chat-agent-item:hover { background: #F9F9F9; }
.chat-agent-item.is-selected { background: #F2F2F2; }
.agent-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #000;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.agent-info { flex: 1; min-width: 0; }
.agent-name { font-size: 12px; font-weight: 600; color: #000; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-type { font-size: 10px; color: #999; text-transform: capitalize; }
.agent-stance-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.stance-supportive { background: #4CAF50; }
.stance-opposing { background: #F44336; }
.stance-neutral { background: #9E9E9E; }
.stance-observer { background: #2196F3; }
.chat-agents-empty { padding: 20px 14px; font-size: 11px; color: #CCC; }
.chat-main { border: 1.5px solid #EFEFEF; border-radius: 4px; display: flex; flex-direction: column; }
.chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.chat-placeholder { font-size: 13px; color: #CCC; text-align: center; margin: auto; }
.chat-msg { display: flex; flex-direction: column; gap: 4px; max-width: 70%; }
.chat-msg--user { align-self: flex-end; }
.chat-msg--agent { align-self: flex-start; }
.chat-msg-label { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: #AAA; }
.chat-msg-text {
padding: 10px 14px;
border-radius: 4px;
font-size: 13px;
line-height: 1.5;
}
.chat-msg--user .chat-msg-text { background: #000; color: #fff; border-radius: 4px 4px 2px 4px; }
.chat-msg--agent .chat-msg-text { background: #F5F5F5; color: #000; border-radius: 4px 4px 4px 2px; }
.chat-thinking {
display: flex;
gap: 4px;
align-items: center;
padding: 12px 14px;
}
.chat-thinking span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #999;
animation: bounce 1s infinite;
}
.chat-thinking span:nth-child(2) { animation-delay: 0.2s; }
.chat-thinking span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 100% { transform: translateY(0); opacity: 0.5; }
50% { transform: translateY(-4px); opacity: 1; }
}
.chat-input-row {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid #EFEFEF;
align-items: flex-end;
}
.chat-input {
flex: 1;
border: 1.5px solid #E0E0E0;
border-radius: 3px;
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
resize: none;
line-height: 1.4;
transition: border-color 0.15s;
}
.chat-input:focus { outline: none; border-color: #000; }
.chat-send-btn {
padding: 10px 14px;
background: #000;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s;
}
.chat-send-btn:hover { background: #222; }
.chat-send-btn:disabled { background: #CCC; cursor: not-allowed; }
</style>

View File

@ -0,0 +1,33 @@
export const RELATIONAL_TYPES = [
'ouvrier_production', 'technicien', 'commercial',
'manager', 'codir', 'client_externe', 'partenaire', 'concurrent',
]
export const RELATIONAL_TYPE_LABELS = {
ouvrier_production: 'Ouvrier / Production',
technicien: 'Technicien',
commercial: 'Commercial',
manager: 'Manager',
codir: 'CODIR',
client_externe: 'Client externe',
partenaire: 'Partenaire',
concurrent: 'Concurrent',
}
export const HORIZON_OPTIONS = [
{ days: 3, label: '3 jours (72h)' },
{ days: 7, label: '7 jours' },
{ days: 30, label: '30 jours' },
{ days: 180, label: '6 mois (180 jours)' },
]
export const ACTION_COLORS = {
CONFRONT: '#F44336',
COALITION_BUILD: '#FF9800',
VOCAL_SUPPORT: '#4CAF50',
SILENT_LEAVE: '#616161',
REACT_PRIVATELY: '#E0E0E0',
DO_NOTHING: '#E0E0E0',
}
export const STEP_NAMES = ['Requirement', 'Prepare', 'Run', 'Report', 'Interact']

View File

@ -0,0 +1,121 @@
export const shortTime = (ts) => {
if (!ts) return ''
try {
return new Date(ts).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return ts.slice(11, 19) || ''
}
}
export const actionTypeClass = (type) => {
if (!type) return ''
const t = type.toLowerCase()
if (t.includes('confront') || t.includes('oppos')) return 'type-hostile'
if (t.includes('support') || t.includes('coalition')) return 'type-support'
if (t.includes('nothing') || t.includes('idle') || t.includes('react_privately')) return 'type-passive'
return 'type-neutral'
}
export const initials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
export const nodeColor = (actionType, ACTION_COLORS) => {
if (!actionType) return '#E0E0E0'
const upper = actionType.toUpperCase()
for (const [key, color] of Object.entries(ACTION_COLORS)) {
if (upper.includes(key)) return color
}
return '#E0E0E0'
}
export const buildRequirement = (form, agentCounts, RELATIONAL_TYPE_LABELS) => {
const parts = []
if (form.decisionMakerName) {
parts.push(`Decision maker: ${form.decisionMakerName}` +
(form.decisionMakerRole ? `${form.decisionMakerRole}` : '') +
(form.decisionMakerCompany ? ` at ${form.decisionMakerCompany}` : ''))
}
parts.push(`Decision: ${form.decisionText}`)
parts.push(`Relational network: ${form.relationalTypes.join(', ')}`)
parts.push(`Temporal horizon: ${form.horizonDays} days`)
if (form.questionsToMeasure) parts.push(`Questions to measure: ${form.questionsToMeasure}`)
const agentDistrib = form.relationalTypes
.map(t => `${RELATIONAL_TYPE_LABELS[t]} × ${agentCounts[t] || 10}`)
.join(', ')
parts.push(`Agent distribution: ${agentDistrib}`)
return parts.join('\n')
}
export const parseImportedConfig = (text, form, agentCounts, RELATIONAL_TYPES, RELATIONAL_TYPE_LABELS) => {
let configText = text
const configMatch = text.match(/#CONFIG\n([\s\S]*?)\n#END_CONFIG/)
if (configMatch) configText = configMatch[1]
const labelToKey = {}
for (const [key, label] of Object.entries(RELATIONAL_TYPE_LABELS)) {
labelToKey[label.toLowerCase()] = key
}
for (const line of configText.split('\n')) {
try {
if (line.startsWith('Décideur :')) {
const val = line.replace('Décideur :', '').trim()
const [nameAndRole, company] = val.split(' at ')
const [name, role] = (nameAndRole || '').split(' — ')
if (name) form.decisionMakerName = name.trim()
if (role) form.decisionMakerRole = role.trim()
if (company) form.decisionMakerCompany = company.trim()
} else if (line.startsWith('Décision :')) {
form.decisionText = line.replace('Décision :', '').trim()
} else if (line.startsWith('Réseau simulé :')) {
const types = line.replace('Réseau simulé :', '').trim()
.split(', ').map(s => s.trim()).filter(t => RELATIONAL_TYPES.includes(t))
if (types.length) form.relationalTypes = types
} else if (line.startsWith('Horizon temporel :')) {
const days = parseInt(line.replace('Horizon temporel :', '').trim(), 10)
if (!isNaN(days)) form.horizonDays = days
} else if (line.startsWith('Questions to measure :')) {
form.questionsToMeasure = line.replace('Questions to measure :', '').trim()
} else if (line.startsWith('Agent distribution:')) {
const entries = line.replace('Agent distribution:', '').trim().split(',')
for (const entry of entries) {
const parts = entry.trim().split(' × ')
if (parts.length !== 2) continue
const key = labelToKey[parts[0].trim().toLowerCase()]
const count = parseInt(parts[1].trim(), 10)
if (key && !isNaN(count)) agentCounts[key] = count
}
}
} catch { /* ligne ignorée */ }
}
}
export const exportReportMarkdown = (reportResult, simId) => {
if (!reportResult) return
let md = reportResult.markdown_content
if (!md) {
const title = reportResult.outline?.title || 'Private Impact Report'
const summary = reportResult.outline?.summary || ''
const sections = reportResult.outline?.sections || []
md = `# ${title}\n\n`
if (summary) md += `> ${summary}\n\n`
sections.forEach((s, idx) => {
const num = String(idx + 1).padStart(2, '0')
md += `## ${num}${s.title || 'Section ' + num}\n\n`
md += `${s.content || ''}\n\n`
})
}
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `private-impact-report-${simId || 'report'}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}