MicroFish/frontend/src/views/PrivateImpactView.vue

1555 lines
51 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="private-view">
<!-- Header -->
<header class="app-header">
<div class="header-left">
<div class="brand" @click="router.push('/')">MIROFISH</div>
</div>
<div class="header-center">
<div class="mode-badge">
<svg viewBox="0 0 24 24" width="12" height="12" 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>
PRIVATE IMPACT
</div>
</div>
<div class="header-right">
<LanguageSwitcher />
<div class="step-divider"></div>
<div class="workflow-step">
<span class="step-num">Step {{ currentStep }}/5</span>
<span class="step-name">{{ stepNames[currentStep - 1] }}</span>
</div>
<div class="step-divider"></div>
<span class="status-indicator" :class="statusClass">
<span class="dot"></span>
{{ statusText }}
</span>
</div>
</header>
<!-- Step breadcrumb -->
<div class="steps-bar">
<div
v-for="(name, idx) in stepNames"
:key="idx"
class="step-node"
:class="{
'is-active': currentStep === idx + 1,
'is-done': currentStep > idx + 1,
}"
@click="currentStep > idx + 1 ? goToStep(idx + 1) : null"
>
<div class="step-circle">
<svg v-if="currentStep > idx + 1" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12" />
</svg>
<span v-else>{{ idx + 1 }}</span>
</div>
<span class="step-node-name">{{ name }}</span>
<div v-if="idx < stepNames.length - 1" class="step-connector" :class="{ 'is-done': currentStep > idx + 1 }"></div>
</div>
</div>
<!-- ── Error banner ── -->
<div v-if="error" class="error-banner">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
{{ error }}
<button class="error-close" @click="error = null">×</button>
</div>
<!-- ══════════════════════════════════════════════════════════
STEP 1 — Requirement Form
══════════════════════════════════════════════════════════ -->
<main v-if="currentStep === 1" class="content-area">
<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="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">{{ t }}</span>
</label>
</div>
</div>
<div class="field-group">
<label class="field-label">TEMPORAL HORIZON — {{ form.horizonDays }} days</label>
<div class="slider-group">
<input
type="range"
class="field-slider"
v-model.number="form.horizonDays"
min="7" max="90" step="1"
/>
<div class="slider-ticks">
<span>7d</span><span>30d</span><span>60d</span><span>90d</span>
</div>
</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"
@click="runPrepare"
>
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>
</main>
<!-- ══════════════════════════════════════════════════════════
STEP 2 — Prepare
══════════════════════════════════════════════════════════ -->
<main v-else-if="currentStep === 2" class="content-area">
<div class="centered-panel">
<!-- Loading -->
<div v-if="isLoading" class="loading-block">
<div class="loading-ring"></div>
<p class="loading-label">Generating relational profiles and behavioural parameters…</p>
<p class="loading-hint">This may take a few seconds per agent. The LLM is building the simulation config.</p>
</div>
<!-- Results -->
<div v-else-if="prepareResult" class="prepare-results">
<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>
Simulation ready
</div>
<div class="result-stats">
<div class="stat-card">
<span class="stat-value mono">{{ prepareResult.agent_count }}</span>
<span class="stat-label">Agents generated</span>
</div>
<div class="stat-card">
<span class="stat-value mono">{{ form.horizonDays }}d</span>
<span class="stat-label">Temporal horizon</span>
</div>
<div class="stat-card">
<span class="stat-value mono">{{ form.relationalTypes.length }}</span>
<span class="stat-label">Relation types</span>
</div>
</div>
<div class="relation-tags">
<span
v-for="t in form.relationalTypes"
:key="t"
class="relation-tag"
>{{ t }}</span>
</div>
<div class="sim-id-block">
<span class="sim-id-label">SIM ID</span>
<span class="sim-id-value mono">{{ simId }}</span>
</div>
<div class="result-actions">
<button class="btn-secondary" @click="goToStep(1)">← Back</button>
<button class="btn-primary" @click="runStart">
Launch Simulation
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</button>
</div>
</div>
</div>
</main>
<!-- ══════════════════════════════════════════════════════════
STEP 3 — Running
══════════════════════════════════════════════════════════ -->
<main v-else-if="currentStep === 3" class="content-area">
<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>
<!-- Progress bar -->
<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="handleStop"
>
Stop Simulation
</button>
<button
v-if="simStatus?.runner_status === 'completed' || simStatus?.runner_status === 'stopped'"
class="btn-primary"
@click="runReport"
>
Generate Report →
</button>
</div>
</div>
<!-- Right: Live action feed -->
<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"
: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>
</main>
<!-- ══════════════════════════════════════════════════════════
STEP 4 — Report
══════════════════════════════════════════════════════════ -->
<main v-else-if="currentStep === 4" class="content-area">
<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>
<h2 class="report-title">{{ reportResult.title }}</h2>
<p class="report-summary">{{ reportResult.summary }}</p>
<div class="report-sections" v-if="reportResult.sections">
<div v-for="(section, idx) in reportResult.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 }}</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>
<div class="result-actions">
<button class="btn-secondary" @click="goToStep(5)">
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="runReport">Retry</button>
</div>
</div>
</main>
<!-- ══════════════════════════════════════════════════════════
STEP 5 — Interaction
══════════════════════════════════════════════════════════ -->
<main v-else-if="currentStep === 5" class="content-area">
<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>
</main>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
import { getProject } from '../api/graph.js'
import { interviewAgents } from '../api/simulation.js'
import { getReportStatus, getReport } from '../api/report.js'
import {
preparePrivateSimulation,
startPrivateSimulation,
getPrivateStatus,
stopPrivateSimulation,
getPrivateActions,
generatePrivateReport,
} from '../api/private.js'
const props = defineProps({
projectId: { type: String, required: true },
})
const router = useRouter()
// ── Constants ──────────────────────────────────────────────────────────────
const RELATIONAL_TYPES = [
'employee', 'manager', 'client', 'competitor',
'partner', 'familymember', 'colleague', 'investor',
]
const stepNames = ['Requirement', 'Prepare', 'Run', 'Report', 'Interact']
// ── State ──────────────────────────────────────────────────────────────────
const currentStep = ref(1)
const projectData = ref(null)
const simId = ref(null)
const simStatus = ref(null)
const prepareResult = ref(null)
const reportResult = ref(null)
const isLoading = ref(false)
const error = ref(null)
const reportProgress = ref('')
// Step 3 - live feed
const recentActions = ref([])
const feedPanel = ref(null)
let pollingTimer = null
// Step 4 - report polling
let reportPollingTimer = null
const collapsedSections = ref(new Set())
// Step 5 - chat
const chatAgents = ref([])
const selectedAgentId = ref(null)
const chatMessages = reactive({}) // agentId -> [{ role, content }]
const chatInput = ref('')
const isChatLoading = ref(false)
const chatMessagesEl = ref(null)
// Form
const form = reactive({
decisionMakerName: '',
decisionMakerRole: '',
decisionMakerCompany: '',
decisionText: '',
decisionContext: '',
relationalTypes: ['employee', 'manager', 'client', 'partner', 'familymember'],
horizonDays: 30,
questionsToMeasure: '',
})
// ── Computed ───────────────────────────────────────────────────────────────
const statusClass = computed(() => {
const s = simStatus.value?.runner_status
if (s === 'running') return 'processing'
if (s === 'completed') return 'completed'
if (s === 'failed') return 'error'
if (isLoading.value) return 'processing'
return 'idle'
})
const statusText = computed(() => {
if (isLoading.value) return 'Processing'
const s = simStatus.value?.runner_status
if (s === 'running') return 'Running'
if (s === 'completed') return 'Completed'
if (s === 'failed') return 'Failed'
return 'Ready'
})
const roundProgress = computed(() => {
const total = simStatus.value?.total_rounds || 0
const current = simStatus.value?.private_current_round || 0
if (!total) return 0
return Math.round((current / total) * 100)
})
const actionTypeCounts = computed(() => {
const counts = {}
for (const action of recentActions.value) {
counts[action.action_type] = (counts[action.action_type] || 0) + 1
}
return counts
})
const selectedAgentName = computed(() => {
const agent = chatAgents.value.find(a => a.agent_id === selectedAgentId.value)
return agent?.entity_name || `Agent ${selectedAgentId.value}`
})
const currentMessages = computed(() => {
return chatMessages[selectedAgentId.value] || []
})
// ── Helpers ────────────────────────────────────────────────────────────────
const buildRequirement = () => {
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}`)
return parts.join('\n')
}
const goToStep = (n) => { currentStep.value = n }
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) || ''
}
}
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'
}
const initials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const toggleSection = (idx) => {
const s = new Set(collapsedSections.value)
s.has(idx) ? s.delete(idx) : s.add(idx)
collapsedSections.value = s
}
// ── Data loading ───────────────────────────────────────────────────────────
onMounted(async () => {
try {
const res = await getProject(props.projectId)
projectData.value = res.data
} catch (e) {
error.value = `Could not load project: ${e.message}`
}
})
onUnmounted(() => {
stopPolling()
stopReportPolling()
})
// ── Step 2: Prepare ────────────────────────────────────────────────────────
const runPrepare = async () => {
if (!projectData.value?.graph_id) {
error.value = 'No graph_id found for this project. Build the graph first.'
return
}
error.value = null
isLoading.value = true
currentStep.value = 2
try {
const res = await preparePrivateSimulation({
graph_id: projectData.value.graph_id,
simulation_requirement: buildRequirement(),
decision_context: form.decisionContext,
entity_types: form.relationalTypes,
})
simId.value = res.data.sim_id
prepareResult.value = res.data
} catch (e) {
error.value = `Prepare failed: ${e.message}`
currentStep.value = 1
} finally {
isLoading.value = false
}
}
// ── Step 3: Start + monitor ────────────────────────────────────────────────
const runStart = async () => {
error.value = null
isLoading.value = true
currentStep.value = 3
try {
await startPrivateSimulation({ sim_id: simId.value })
startPolling()
} catch (e) {
error.value = `Start failed: ${e.message}`
currentStep.value = 2
} finally {
isLoading.value = false
}
}
const startPolling = () => {
pollingTimer = setInterval(pollStatus, 3000)
pollStatus()
}
const stopPolling = () => {
if (pollingTimer) { clearInterval(pollingTimer); pollingTimer = null }
}
const pollStatus = async () => {
if (!simId.value) return
try {
const res = await getPrivateStatus(simId.value)
simStatus.value = res.data
recentActions.value = res.data.recent_actions || []
const status = res.data.runner_status
if (status === 'completed' || status === 'stopped' || status === 'failed') {
stopPolling()
// Fetch full action list for the feed
try {
const actRes = await getPrivateActions(simId.value)
recentActions.value = actRes.data || []
} catch { /* keep recent from status */ }
}
} catch (e) {
console.error('Status poll error:', e)
}
}
const handleStop = async () => {
try {
stopPolling()
await stopPrivateSimulation(simId.value)
const res = await getPrivateStatus(simId.value)
simStatus.value = res.data
} catch (e) {
error.value = `Stop failed: ${e.message}`
}
}
// ── Step 4: Report ─────────────────────────────────────────────────────────
const runReport = async () => {
error.value = null
isLoading.value = true
reportProgress.value = 'Initialising Report Agent…'
currentStep.value = 4
try {
const res = await generatePrivateReport(simId.value)
const reportId = res.data.report_id
startReportPolling(reportId)
} catch (e) {
error.value = `Report trigger failed: ${e.message}`
isLoading.value = false
}
}
const startReportPolling = (reportId) => {
reportPollingTimer = setInterval(() => pollReport(reportId), 4000)
pollReport(reportId)
}
const stopReportPolling = () => {
if (reportPollingTimer) { clearInterval(reportPollingTimer); reportPollingTimer = null }
}
const pollReport = async (reportId) => {
try {
const res = await getReportStatus(reportId)
const status = res.data?.status
reportProgress.value = res.data?.message || 'Generating…'
if (status === 'completed') {
stopReportPolling()
const fullRes = await getReport(reportId)
reportResult.value = fullRes.data
isLoading.value = false
} else if (status === 'failed') {
stopReportPolling()
error.value = `Report failed: ${res.data?.error}`
isLoading.value = false
}
} catch (e) {
console.error('Report poll error:', e)
}
}
// ── Step 5: Interaction ────────────────────────────────────────────────────
watch(() => currentStep.value, async (step) => {
if (step === 5 && chatAgents.value.length === 0) {
loadChatAgents()
}
})
const loadChatAgents = async () => {
try {
const res = await getPrivateActions(simId.value)
const agentMap = {}
for (const action of (res.data || [])) {
if (!agentMap[action.agent_id]) {
agentMap[action.agent_id] = {
agent_id: action.agent_id,
entity_name: action.agent_name || `Agent ${action.agent_id}`,
relational_link_type: action.action_args?.relational_link_type || '',
stance: action.action_args?.stance || 'neutral',
}
}
}
chatAgents.value = Object.values(agentMap)
} catch (e) {
console.error('Could not load agents:', e)
}
}
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 res = await interviewAgents({
simulation_id: simId.value,
agent_ids: [selectedAgentId.value],
prompt: userMsg,
chat_history: history,
})
const reply = res.data?.[0]?.response || res.data?.response || '(no 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()
}
}
const scrollChat = () => {
if (chatMessagesEl.value) {
chatMessagesEl.value.scrollTop = chatMessagesEl.value.scrollHeight
}
}
// Auto-scroll feed
watch(() => recentActions.value.length, () => {
nextTick(() => {
if (feedPanel.value) feedPanel.value.scrollTop = feedPanel.value.scrollHeight
})
})
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────── */
.private-view {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: #fff;
font-family: 'JetBrains Mono', 'Space Grotesk', 'Noto Sans SC', monospace;
}
.content-area {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
padding: 0 24px;
height: 52px;
border-bottom: 1px solid #E8E8E8;
background: #fff;
flex-shrink: 0;
gap: 16px;
}
.brand {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.12em;
cursor: pointer;
color: #000;
}
.header-left { min-width: 120px; }
.header-center { flex: 1; display: flex; justify-content: center; }
.header-right { min-width: 300px; display: flex; align-items: center; gap: 12px; justify-content: flex-end; }
.mode-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
background: #000;
color: #fff;
padding: 4px 10px;
border-radius: 2px;
}
.step-divider { width: 1px; height: 20px; background: #E0E0E0; }
.workflow-step { display: flex; flex-direction: column; align-items: flex-end; gap: 1px; }
.step-num { font-size: 10px; font-weight: 600; letter-spacing: 0.1em; color: #999; }
.step-name { font-size: 11px; font-weight: 600; color: #333; }
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: #888;
}
.status-indicator .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ccc;
}
.status-indicator.processing { color: #FF5722; }
.status-indicator.processing .dot { background: #FF5722; animation: pulse 1s infinite; }
.status-indicator.completed { color: #2E7D32; }
.status-indicator.completed .dot { background: #4CAF50; }
.status-indicator.error { color: #C62828; }
.status-indicator.error .dot { background: #F44336; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Steps bar ───────────────────────────────────────────────────────────── */
.steps-bar {
display: flex;
align-items: center;
padding: 12px 32px;
border-bottom: 1px solid #EFEFEF;
background: #FAFAFA;
flex-shrink: 0;
gap: 0;
}
.step-node {
display: flex;
align-items: center;
gap: 8px;
position: relative;
}
.step-circle {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1.5px solid #D0D0D0;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #999;
flex-shrink: 0;
transition: all 0.2s;
}
.step-node.is-active .step-circle {
border-color: #000;
background: #000;
color: #fff;
}
.step-node.is-done .step-circle {
border-color: #4CAF50;
background: #4CAF50;
color: #fff;
}
.step-node-name {
font-size: 11px;
font-weight: 600;
color: #AAA;
white-space: nowrap;
}
.step-node.is-active .step-node-name { color: #000; }
.step-node.is-done { cursor: pointer; }
.step-node.is-done .step-node-name { color: #555; }
.step-connector {
width: 32px;
height: 1.5px;
background: #E0E0E0;
margin: 0 8px;
flex-shrink: 0;
}
.step-connector.is-done { background: #4CAF50; }
/* ── Error banner ────────────────────────────────────────────────────────── */
.error-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 32px;
background: #FFF3F3;
border-bottom: 1px solid #FFCDD2;
font-size: 12px;
color: #C62828;
}
.error-close {
margin-left: auto;
background: none;
border: none;
font-size: 16px;
cursor: pointer;
color: #C62828;
padding: 0 4px;
}
/* ── Form (Step 1) ───────────────────────────────────────────────────────── */
.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; }
.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; }
.slider-group { display: flex; flex-direction: column; gap: 6px; }
.field-slider {
width: 100%;
accent-color: #000;
cursor: pointer;
}
.slider-ticks {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #AAA;
letter-spacing: 0.04em;
}
.form-footer { margin-top: 28px; display: flex; justify-content: flex-end; }
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.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-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; }
.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;
}
/* ── Centered panel (steps 2, 4) ─────────────────────────────────────────── */
.centered-panel {
max-width: 680px;
margin: 0 auto;
}
/* ── Loading ─────────────────────────────────────────────────────────────── */
.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; }
/* ── Prepare results (step 2) ────────────────────────────────────────────── */
.prepare-results { display: flex; flex-direction: column; gap: 20px; padding: 20px 0; }
.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-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.stat-card {
border: 1.5px solid #E8E8E8;
border-radius: 4px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-value { font-size: 28px; font-weight: 700; color: #000; }
.stat-label { font-size: 10px; font-weight: 600; letter-spacing: 0.1em; color: #888; text-transform: uppercase; }
.relation-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.relation-tag {
font-size: 11px;
padding: 3px 8px;
background: #F0F0F0;
border-radius: 2px;
color: #444;
font-weight: 500;
text-transform: capitalize;
}
.sim-id-block { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: #F7F7F7; border-radius: 3px; }
.sim-id-label { font-size: 10px; font-weight: 700; letter-spacing: 0.1em; color: #999; }
.sim-id-value { font-size: 12px; color: #333; }
.result-actions { display: flex; gap: 10px; }
/* ── Run layout (step 3) ─────────────────────────────────────────────────── */
.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; }
/* ── Feed panel (step 3 right) ───────────────────────────────────────────── */
.run-feed-panel {
border: 1.5px solid #EFEFEF;
border-radius: 4px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.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; }
/* ── Report (step 4) ─────────────────────────────────────────────────────── */
.report-ready { display: flex; flex-direction: column; gap: 20px; padding: 20px 0; }
.report-title { font-size: 22px; font-weight: 700; color: #000; line-height: 1.3; }
.report-summary { font-size: 14px; color: #555; line-height: 1.6; }
.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; }
/* ── Chat (step 5) ───────────────────────────────────────────────────────── */
.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; }
/* ── Mono utility ────────────────────────────────────────────────────────── */
.mono { font-family: 'JetBrains Mono', monospace; }
</style>