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:
parent
074c03d685
commit
e4fe3f91aa
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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']
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue