diff --git a/frontend/src/components/Step2EnvSetup.vue b/frontend/src/components/Step2EnvSetup.vue
index a27ba347..16d6170c 100644
--- a/frontend/src/components/Step2EnvSetup.vue
+++ b/frontend/src/components/Step2EnvSetup.vue
@@ -83,15 +83,17 @@
{{ $t('step2.generatedAgentPersonas') }}
@@ -639,7 +731,12 @@ import {
getPrepareStatus,
getSimulationProfilesRealtime,
getSimulationConfig,
- getSimulationConfigRealtime
+ getSimulationConfigRealtime,
+ patchAgent,
+ deleteAgent,
+ regenerateAgent,
+ generateConfig,
+ patchSimulationConfig
} from '../api/simulation'
const { t } = useI18n()
@@ -675,6 +772,29 @@ let lastLoggedConfigStage = ''
const useCustomRounds = ref(false) // 默认使用自动配置轮数
const customMaxRounds = ref(40) // 默认推荐40轮
+// Fase A/B state
+const currentPhase = ref('generating') // 'generating' | 'phase_a' | 'phase_b'
+const agentModalOpen = ref(false)
+const agentModalMode = ref('view') // 'view' | 'edit' | 'regen'
+const selectedAgent = ref(null)
+const editForm = ref({})
+const regenInstructions = ref('')
+const regenLoading = ref(false)
+const editLoading = ref(false)
+const deleteConfirmAgent = ref(null)
+const generateConfigLoading = ref(false)
+const generateConfigTaskId = ref(null)
+
+// Fase B state
+const phaseBConfig = ref(null)
+const phaseBSaving = ref(false)
+const configForm = ref({
+ total_simulation_hours: 24,
+ minutes_per_round: 60,
+ following_probability: 0.05,
+ recsys_type: 'random',
+})
+
// Watch stage to update phase
watch(currentStage, (newStage) => {
if (newStage === '生成Agent人设' || newStage === 'generating_profiles') {
@@ -892,7 +1012,15 @@ const pollPrepareStatus = async () => {
}
// 检查是否完成
- if (data.status === 'completed' || data.status === 'ready' || data.already_prepared) {
+ if (data.status === 'profiles_ready') {
+ addLog(t('log.prepareComplete'))
+ stopPolling()
+ stopProfilesPolling()
+ await fetchProfilesRealtime()
+ addLog(t('log.loadedAgentProfiles', { count: profiles.value.length }))
+ currentPhase.value = 'phase_a'
+ emit('update-status', 'profiles_ready')
+ } else if (data.status === 'completed' || data.status === 'ready' || data.already_prepared) {
addLog(t('log.prepareComplete'))
stopPolling()
stopProfilesPolling()
@@ -1058,6 +1186,136 @@ const loadPreparedData = async () => {
}
}
+// ---- Fase A/B helpers ----
+
+const pollTaskUntilDone = async (taskId, onComplete, intervalMs = 2000) => {
+ return new Promise((resolve) => {
+ const interval = setInterval(async () => {
+ try {
+ const res = await getPrepareStatus({
+ task_id: taskId,
+ simulation_id: props.simulationId
+ })
+ const status = res.data?.status || res.data?.data?.status
+ if (status === 'completed') {
+ clearInterval(interval)
+ onComplete && onComplete(res.data?.result || res.data?.data?.result)
+ resolve()
+ } else if (status === 'failed') {
+ clearInterval(interval)
+ resolve()
+ }
+ } catch {
+ clearInterval(interval)
+ resolve()
+ }
+ }, intervalMs)
+ })
+}
+
+const openAgentModal = (agent, mode = 'view') => {
+ selectedAgent.value = agent
+ agentModalMode.value = mode
+ editForm.value = { ...agent }
+ regenInstructions.value = ''
+ agentModalOpen.value = true
+}
+
+const closeAgentModal = () => {
+ agentModalOpen.value = false
+ selectedAgent.value = null
+}
+
+const saveAgent = async () => {
+ if (!selectedAgent.value || !props.simulationId) return
+ editLoading.value = true
+ try {
+ const res = await patchAgent(props.simulationId, selectedAgent.value.user_id, editForm.value)
+ if (res.data?.success) {
+ const idx = profiles.value.findIndex(p => p.user_id === selectedAgent.value.user_id)
+ if (idx !== -1) profiles.value[idx] = res.data.data
+ closeAgentModal()
+ }
+ } finally {
+ editLoading.value = false
+ }
+}
+
+const confirmDeleteAgent = async (agent) => {
+ if (!confirm(t('step2.deleteAgentConfirm'))) return
+ const res = await deleteAgent(props.simulationId, agent.user_id)
+ if (res.data?.success) {
+ profiles.value = profiles.value.filter(p => p.user_id !== agent.user_id)
+ }
+}
+
+const doRegenerate = async () => {
+ if (!selectedAgent.value) return
+ regenLoading.value = true
+ try {
+ const res = await regenerateAgent(props.simulationId, selectedAgent.value.user_id, {
+ extra_instructions: regenInstructions.value
+ })
+ if (res.data?.success) {
+ await pollTaskUntilDone(res.data.data?.task_id, () => {})
+ // Refresh profiles
+ await fetchProfilesRealtime()
+ closeAgentModal()
+ }
+ } finally {
+ regenLoading.value = false
+ }
+}
+
+const continueToPhaseB = async () => {
+ generateConfigLoading.value = true
+ try {
+ const res = await generateConfig(props.simulationId)
+ if (res.data?.success) {
+ generateConfigTaskId.value = res.data.data?.task_id
+ await pollTaskUntilDone(res.data.data?.task_id, async () => {
+ // Load config for display
+ const configRes = await getSimulationConfig(props.simulationId)
+ if (configRes.data?.success) {
+ phaseBConfig.value = configRes.data.data
+ const tc = phaseBConfig.value?.time_config || {}
+ configForm.value = {
+ total_simulation_hours: tc.total_simulation_hours ?? 24,
+ minutes_per_round: tc.minutes_per_round ?? 60,
+ following_probability: phaseBConfig.value?.following_probability ?? 0.05,
+ recsys_type: phaseBConfig.value?.recsys_type ?? 'random',
+ }
+ }
+ currentPhase.value = 'phase_b'
+ })
+ // If task_id is null, generateConfig was synchronous — set phase_b directly
+ if (!res.data.data?.task_id) {
+ currentPhase.value = 'phase_b'
+ }
+ }
+ } finally {
+ generateConfigLoading.value = false
+ }
+}
+
+const saveGlobalConfig = async () => {
+ phaseBSaving.value = true
+ try {
+ await patchSimulationConfig(props.simulationId, configForm.value)
+ } finally {
+ phaseBSaving.value = false
+ }
+}
+
+const launchSimulation = async () => {
+ await saveGlobalConfig()
+ const params = {}
+ if (useCustomRounds.value) {
+ params.maxRounds = customMaxRounds.value
+ }
+ emit('next-step', params)
+}
+
// Scroll log to bottom
const logContent = ref(null)
watch(() => props.systemLogs?.length, () => {
@@ -2602,4 +2860,86 @@ onUnmounted(() => {
transform: scale(0.95) translateY(10px);
opacity: 0;
}
+
+/* Fase A footer */
+.phase-a-footer {
+ display: flex;
+ justify-content: flex-end;
+ padding: 16px 0;
+ border-top: 1px solid #EAEAEA;
+ margin-top: 16px;
+}
+
+.continue-btn {
+ padding: 10px 24px;
+ background: #000;
+ color: #FFF;
+ border: none;
+ border-radius: 6px;
+ font-weight: 700;
+ font-size: 14px;
+ cursor: pointer;
+}
+.continue-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+/* Fase B section */
+.phase-b-section { padding: 16px 0; }
+.section-subtitle { font-size: 13px; color: #666; margin-bottom: 16px; }
+.config-form { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0; }
+.config-field label { display: block; font-size: 12px; font-weight: 600; color: #666; margin-bottom: 4px; text-transform: uppercase; }
+.config-field input, .config-field select { width: 100%; padding: 8px 12px; border: 1px solid #E0E0E0; border-radius: 6px; font-size: 14px; box-sizing: border-box; }
+.phase-b-footer { display: flex; justify-content: space-between; padding-top: 16px; border-top: 1px solid #EAEAEA; }
+
+/* Agent action button and badge */
+.agent-action-btn { background: none; border: none; cursor: pointer; padding: 4px 8px; font-size: 18px; color: #666; margin-left: auto; }
+.agent-action-btn:hover { color: #000; }
+.manually-edited-badge {
+ background: #FFF3CD; color: #856404;
+ padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;
+}
+
+/* Agent edit/regen modal */
+.agent-modal-overlay {
+ position: fixed; inset: 0; background: rgba(0,0,0,0.4);
+ display: flex; align-items: center; justify-content: center;
+ z-index: 1000;
+}
+
+.agent-modal {
+ background: #FFF; border-radius: 12px; padding: 24px;
+ width: 560px; max-height: 80vh; overflow-y: auto;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.15);
+}
+
+.agent-modal .modal-header {
+ display: flex; align-items: center; gap: 8px;
+ margin-bottom: 16px;
+ padding: 0;
+ border-bottom: none;
+}
+
+.modal-title { font-weight: 700; font-size: 18px; flex: 1; }
+
+.edited-badge {
+ background: #FFF3CD; color: #856404;
+ padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;
+}
+
+.modal-close { background: none; border: none; cursor: pointer; font-size: 18px; }
+
+.agent-modal .modal-body { padding: 0; overflow: visible; flex: unset; }
+.agent-modal .modal-body.modal-view p { font-size: 14px; color: #444; line-height: 1.6; margin-bottom: 10px; }
+.agent-modal .field-group { margin-bottom: 12px; }
+.agent-modal label { display: block; font-size: 12px; font-weight: 600; color: #666; margin-bottom: 4px; text-transform: uppercase; }
+.agent-modal input, .agent-modal textarea {
+ width: 100%; padding: 8px 12px; border: 1px solid #E0E0E0;
+ border-radius: 6px; font-size: 14px; resize: vertical;
+ box-sizing: border-box;
+}
+
+.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
+.btn-primary { padding: 8px 16px; background: #000; color: #FFF; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
+.btn-secondary { padding: 8px 16px; background: #F5F5F5; color: #333; border: 1px solid #DDD; border-radius: 6px; cursor: pointer; font-weight: 600; }
+.btn-danger { padding: 8px 16px; background: #FFF; color: #D32F2F; border: 1px solid #FFCDD2; border-radius: 6px; cursor: pointer; font-weight: 600; }
+.btn-primary:disabled, .btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
diff --git a/frontend/src/components/Step3Simulation.vue b/frontend/src/components/Step3Simulation.vue
index 9f2b2977..397d2325 100644
--- a/frontend/src/components/Step3Simulation.vue
+++ b/frontend/src/components/Step3Simulation.vue
@@ -91,7 +91,14 @@
-
+