Files
microfish/frontend/src/components/Step5Interaction.vue
666ghj 70922a3525 Implement interaction features in Step5Interaction component and add routing for interaction view
- Introduced the Step5Interaction component for user interaction with report agents and simulation profiles.
- Added chat functionality to communicate with report agents and selected simulation agents.
- Implemented a survey feature to gather responses from multiple agents.
- Enhanced routing by adding a new InteractionView for seamless navigation to the interaction interface.
- Updated the router configuration to include the new interaction route.
- Improved UI elements and styles for better user experience in the interaction process.
2025-12-16 17:50:43 +08:00

1918 lines
44 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="interaction-panel">
<!-- Main Split Layout -->
<div class="main-split-layout">
<!-- LEFT PANEL: Report Style -->
<div class="left-panel report-style" ref="leftPanel">
<div v-if="reportOutline" class="report-content-wrapper">
<!-- Report Header -->
<div class="report-header-block">
<div class="report-meta">
<span class="report-tag">Prediction Report</span>
<span class="report-id">ID: {{ reportId || 'REF-2024-X92' }}</span>
</div>
<h1 class="main-title">{{ reportOutline.title }}</h1>
<p class="sub-title">{{ reportOutline.summary }}</p>
<div class="header-divider"></div>
</div>
<!-- Sections List -->
<div class="sections-list">
<div
v-for="(section, idx) in reportOutline.sections"
:key="idx"
class="report-section-item"
:class="{
'is-active': currentSectionIndex === idx + 1,
'is-completed': isSectionCompleted(idx + 1),
'is-pending': !isSectionCompleted(idx + 1) && currentSectionIndex !== idx + 1
}"
>
<div class="section-header-row" @click="toggleSectionCollapse(idx)" :class="{ 'clickable': isSectionCompleted(idx + 1) }">
<h3 class="section-title">{{ section.title }}</h3>
<svg
v-if="isSectionCompleted(idx + 1)"
class="collapse-icon"
:class="{ 'is-collapsed': collapsedSections.has(idx) }"
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
<div class="section-body" v-show="!collapsedSections.has(idx)">
<!-- Completed Content -->
<div v-if="generatedSections[idx + 1]" class="generated-content" v-html="renderMarkdown(generatedSections[idx + 1])"></div>
<!-- Loading State -->
<div v-else-if="currentSectionIndex === idx + 1" class="loading-state">
<div class="loading-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="4" stroke="#E5E7EB"></circle>
<path d="M12 2a10 10 0 0 1 10 10" stroke-width="4" stroke="#4B5563" stroke-linecap="round"></path>
</svg>
</div>
<span class="loading-text">正在生成{{ section.title }}...</span>
</div>
</div>
</div>
</div>
</div>
<!-- Waiting State -->
<div v-if="!reportOutline" class="waiting-placeholder">
<div class="waiting-animation">
<div class="waiting-ring"></div>
<div class="waiting-ring"></div>
<div class="waiting-ring"></div>
</div>
<span class="waiting-text">Waiting for Report Agent...</span>
</div>
</div>
<!-- RIGHT PANEL: Interaction Interface -->
<div class="right-panel" ref="rightPanel">
<!-- Tab Switcher -->
<div class="interaction-header">
<div class="tab-switcher">
<button
class="tab-btn"
:class="{ active: activeTab === 'chat' }"
@click="activeTab = 'chat'"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
<span>单独对话</span>
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'survey' }"
@click="activeTab = 'survey'"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3L22 4"></path>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
<span>问卷调查</span>
</button>
</div>
</div>
<!-- Chat Mode -->
<div v-if="activeTab === 'chat'" class="chat-container">
<!-- Target Selector -->
<div class="target-selector">
<div class="selector-label">对话对象</div>
<div class="selector-options">
<button
class="target-option"
:class="{ active: chatTarget === 'report_agent' }"
@click="selectChatTarget('report_agent')"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
</svg>
<span>Report Agent</span>
</button>
<div class="agent-dropdown" v-if="profiles.length > 0">
<button
class="target-option agent-option"
:class="{ active: chatTarget === 'agent' }"
@click="toggleAgentDropdown"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<span>{{ selectedAgent ? selectedAgent.username : '选择模拟个体' }}</span>
<svg class="dropdown-arrow" :class="{ open: showAgentDropdown }" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div v-if="showAgentDropdown" class="dropdown-menu">
<div
v-for="(agent, idx) in profiles"
:key="idx"
class="dropdown-item"
@click="selectAgent(agent, idx)"
>
<div class="agent-avatar">{{ (agent.username || 'A')[0] }}</div>
<div class="agent-info">
<span class="agent-name">{{ agent.username }}</span>
<span class="agent-role">{{ agent.profession || '未知职业' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Chat Messages -->
<div class="chat-messages" ref="chatMessages">
<div v-if="chatHistory.length === 0" class="chat-empty">
<div class="empty-icon">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<p class="empty-text">
{{ chatTarget === 'report_agent' ? '与 Report Agent 对话,深入了解报告内容' : '与模拟个体对话,了解他们的观点' }}
</p>
</div>
<div
v-for="(msg, idx) in chatHistory"
:key="idx"
class="chat-message"
:class="msg.role"
>
<div class="message-avatar">
<span v-if="msg.role === 'user'">U</span>
<span v-else>{{ msg.role === 'assistant' && chatTarget === 'report_agent' ? 'R' : (selectedAgent?.username?.[0] || 'A') }}</span>
</div>
<div class="message-content">
<div class="message-header">
<span class="sender-name">
{{ msg.role === 'user' ? 'You' : (chatTarget === 'report_agent' ? 'Report Agent' : (selectedAgent?.username || 'Agent')) }}
</span>
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
</div>
<div class="message-text" v-html="renderMarkdown(msg.content)"></div>
</div>
</div>
<div v-if="isSending" class="chat-message assistant">
<div class="message-avatar">
<span>{{ chatTarget === 'report_agent' ? 'R' : (selectedAgent?.username?.[0] || 'A') }}</span>
</div>
<div class="message-content">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
<!-- Chat Input -->
<div class="chat-input-area">
<textarea
v-model="chatInput"
class="chat-input"
placeholder="输入您的问题..."
@keydown.enter.exact.prevent="sendMessage"
:disabled="isSending || (!selectedAgent && chatTarget === 'agent')"
rows="1"
ref="chatInputRef"
></textarea>
<button
class="send-btn"
@click="sendMessage"
:disabled="!chatInput.trim() || isSending || (!selectedAgent && chatTarget === 'agent')"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</div>
</div>
<!-- Survey Mode -->
<div v-if="activeTab === 'survey'" class="survey-container">
<!-- Survey Setup -->
<div class="survey-setup">
<div class="setup-section">
<div class="section-header">
<span class="section-title">选择调查对象</span>
<span class="selection-count">已选 {{ selectedAgents.size }} / {{ profiles.length }}</span>
</div>
<div class="agents-grid">
<label
v-for="(agent, idx) in profiles"
:key="idx"
class="agent-checkbox"
:class="{ checked: selectedAgents.has(idx) }"
>
<input
type="checkbox"
:checked="selectedAgents.has(idx)"
@change="toggleAgentSelection(idx)"
>
<div class="checkbox-avatar">{{ (agent.username || 'A')[0] }}</div>
<div class="checkbox-info">
<span class="checkbox-name">{{ agent.username }}</span>
<span class="checkbox-role">{{ agent.profession || '未知职业' }}</span>
</div>
<div class="checkbox-indicator">
<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"></polyline>
</svg>
</div>
</label>
</div>
<div class="selection-actions">
<button class="action-link" @click="selectAllAgents">全选</button>
<span class="action-divider">|</span>
<button class="action-link" @click="clearAgentSelection">清空</button>
</div>
</div>
<div class="setup-section">
<div class="section-header">
<span class="section-title">问卷问题</span>
</div>
<textarea
v-model="surveyQuestion"
class="survey-input"
placeholder="输入您想问所有被选中对象的问题..."
rows="3"
></textarea>
</div>
<button
class="survey-submit-btn"
:disabled="selectedAgents.size === 0 || !surveyQuestion.trim() || isSurveying"
@click="submitSurvey"
>
<span v-if="isSurveying" class="loading-spinner"></span>
<span v-else>发送问卷</span>
</button>
</div>
<!-- Survey Results -->
<div v-if="surveyResults.length > 0" class="survey-results">
<div class="results-header">
<span class="results-title">调查结果</span>
<span class="results-count">{{ surveyResults.length }} 条回复</span>
</div>
<div class="results-list">
<div
v-for="(result, idx) in surveyResults"
:key="idx"
class="result-card"
>
<div class="result-header">
<div class="result-avatar">{{ (result.agent_name || 'A')[0] }}</div>
<div class="result-info">
<span class="result-name">{{ result.agent_name }}</span>
<span class="result-role">{{ result.profession || '未知职业' }}</span>
</div>
</div>
<div class="result-question">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span>{{ result.question }}</span>
</div>
<div class="result-answer" v-html="renderMarkdown(result.answer)"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom Console Logs -->
<div class="console-logs">
<div class="log-header">
<span class="log-title">INTERACTION LOG</span>
<span class="log-id">{{ simulationId || 'NO_SIMULATION' }}</span>
</div>
<div class="log-content" ref="logContent">
<div class="log-line" v-for="(log, idx) in systemLogs" :key="idx">
<span class="log-time">{{ log.time }}</span>
<span class="log-msg">{{ log.msg }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { chatWithReport, getReport, getAgentLog } from '../api/report'
import { interviewAgents, getSimulationProfilesRealtime } from '../api/simulation'
const props = defineProps({
reportId: String,
simulationId: String,
systemLogs: Array
})
const emit = defineEmits(['add-log', 'update-status'])
// State
const activeTab = ref('chat')
const chatTarget = ref('report_agent')
const showAgentDropdown = ref(false)
const selectedAgent = ref(null)
const selectedAgentIndex = ref(null)
// Chat State
const chatInput = ref('')
const chatHistory = ref([])
const isSending = ref(false)
const chatMessages = ref(null)
const chatInputRef = ref(null)
// Survey State
const selectedAgents = ref(new Set())
const surveyQuestion = ref('')
const surveyResults = ref([])
const isSurveying = ref(false)
// Report Data
const reportOutline = ref(null)
const generatedSections = ref({})
const collapsedSections = ref(new Set())
const currentSectionIndex = ref(null)
const profiles = ref([])
// Helper Methods
const isSectionCompleted = (sectionIndex) => {
return !!generatedSections.value[sectionIndex]
}
// Refs
const leftPanel = ref(null)
const rightPanel = ref(null)
const logContent = ref(null)
// Methods
const addLog = (msg) => {
emit('add-log', msg)
}
const toggleSectionCollapse = (idx) => {
if (!generatedSections.value[idx + 1]) return
const newSet = new Set(collapsedSections.value)
if (newSet.has(idx)) {
newSet.delete(idx)
} else {
newSet.add(idx)
}
collapsedSections.value = newSet
}
const selectChatTarget = (target) => {
chatTarget.value = target
if (target === 'report_agent') {
showAgentDropdown.value = false
}
}
const toggleAgentDropdown = () => {
showAgentDropdown.value = !showAgentDropdown.value
if (showAgentDropdown.value) {
chatTarget.value = 'agent'
}
}
const selectAgent = (agent, idx) => {
selectedAgent.value = agent
selectedAgentIndex.value = idx
chatTarget.value = 'agent'
showAgentDropdown.value = false
chatHistory.value = [] // Reset chat history for new agent
addLog(`选择对话对象: ${agent.username}`)
}
const formatTime = (timestamp) => {
if (!timestamp) return ''
try {
return new Date(timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit'
})
} catch {
return ''
}
}
const renderMarkdown = (content) => {
if (!content) return ''
let processedContent = content.replace(/^##\s+.+\n+/, '')
let html = processedContent.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre class="code-block"><code>$2</code></pre>')
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
html = html.replace(/^#### (.+)$/gm, '<h5 class="md-h5">$1</h5>')
html = html.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>')
html = html.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>')
html = html.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>')
html = html.replace(/^> (.+)$/gm, '<blockquote class="md-quote">$1</blockquote>')
html = html.replace(/^- (.+)$/gm, '<li class="md-li">$1</li>')
html = html.replace(/(<li class="md-li">[\s\S]*?<\/li>)(\s*<li)/g, '$1$2')
html = html.replace(/(<li class="md-li">.*<\/li>)+/g, '<ul class="md-ul">$&</ul>')
html = html.replace(/^\d+\. (.+)$/gm, '<li class="md-oli">$1</li>')
html = html.replace(/(<li class="md-oli">.*<\/li>)+/g, '<ol class="md-ol">$&</ol>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/_(.+?)_/g, '<em>$1</em>')
html = html.replace(/^---$/gm, '<hr class="md-hr">')
html = html.replace(/\n\n/g, '</p><p class="md-p">')
html = html.replace(/\n/g, '<br>')
html = '<p class="md-p">' + html + '</p>'
html = html.replace(/<p class="md-p"><\/p>/g, '')
html = html.replace(/<p class="md-p">(<h[2-5])/g, '$1')
html = html.replace(/(<\/h[2-5]>)<\/p>/g, '$1')
html = html.replace(/<p class="md-p">(<ul|<ol|<blockquote|<pre|<hr)/g, '$1')
html = html.replace(/(<\/ul>|<\/ol>|<\/blockquote>|<\/pre>)<\/p>/g, '$1')
return html
}
// Chat Methods
const sendMessage = async () => {
if (!chatInput.value.trim() || isSending.value) return
const message = chatInput.value.trim()
chatInput.value = ''
// Add user message
chatHistory.value.push({
role: 'user',
content: message,
timestamp: new Date().toISOString()
})
scrollToBottom()
isSending.value = true
try {
if (chatTarget.value === 'report_agent') {
await sendToReportAgent(message)
} else {
await sendToAgent(message)
}
} catch (err) {
addLog(`发送失败: ${err.message}`)
chatHistory.value.push({
role: 'assistant',
content: `抱歉,发生了错误: ${err.message}`,
timestamp: new Date().toISOString()
})
} finally {
isSending.value = false
scrollToBottom()
}
}
const sendToReportAgent = async (message) => {
addLog(`向 Report Agent 发送: ${message.substring(0, 50)}...`)
// Build chat history for API
const historyForApi = chatHistory.value
.filter(msg => msg.role !== 'user' || msg.content !== message)
.slice(-10) // Keep last 10 messages
.map(msg => ({
role: msg.role,
content: msg.content
}))
const res = await chatWithReport({
simulation_id: props.simulationId,
message: message,
chat_history: historyForApi
})
if (res.success && res.data) {
chatHistory.value.push({
role: 'assistant',
content: res.data.response || res.data.answer || '无响应',
timestamp: new Date().toISOString()
})
addLog('Report Agent 已回复')
} else {
throw new Error(res.error || '请求失败')
}
}
const sendToAgent = async (message) => {
if (!selectedAgent.value || selectedAgentIndex.value === null) {
throw new Error('请先选择一个模拟个体')
}
addLog(`${selectedAgent.value.username} 发送: ${message.substring(0, 50)}...`)
// Build prompt with chat history
let prompt = message
if (chatHistory.value.length > 1) {
const historyContext = chatHistory.value
.filter(msg => msg.content !== message)
.slice(-6)
.map(msg => `${msg.role === 'user' ? '提问者' : '你'}${msg.content}`)
.join('\n')
prompt = `以下是我们之前的对话:\n${historyContext}\n\n现在我的新问题是${message}`
}
const res = await interviewAgents({
simulation_id: props.simulationId,
interviews: [{
agent_id: selectedAgentIndex.value,
prompt: prompt
}]
})
if (res.success && res.data) {
const results = res.data.results || res.data
if (results && results.length > 0) {
chatHistory.value.push({
role: 'assistant',
content: results[0].response || results[0].answer || '无响应',
timestamp: new Date().toISOString()
})
addLog(`${selectedAgent.value.username} 已回复`)
} else {
throw new Error('无响应数据')
}
} else {
throw new Error(res.error || '请求失败')
}
}
const scrollToBottom = () => {
nextTick(() => {
if (chatMessages.value) {
chatMessages.value.scrollTop = chatMessages.value.scrollHeight
}
})
}
// Survey Methods
const toggleAgentSelection = (idx) => {
const newSet = new Set(selectedAgents.value)
if (newSet.has(idx)) {
newSet.delete(idx)
} else {
newSet.add(idx)
}
selectedAgents.value = newSet
}
const selectAllAgents = () => {
const newSet = new Set()
profiles.value.forEach((_, idx) => newSet.add(idx))
selectedAgents.value = newSet
}
const clearAgentSelection = () => {
selectedAgents.value = new Set()
}
const submitSurvey = async () => {
if (selectedAgents.value.size === 0 || !surveyQuestion.value.trim()) return
isSurveying.value = true
addLog(`发送问卷给 ${selectedAgents.value.size} 个对象...`)
try {
const interviews = Array.from(selectedAgents.value).map(idx => ({
agent_id: idx,
prompt: surveyQuestion.value.trim()
}))
const res = await interviewAgents({
simulation_id: props.simulationId,
interviews: interviews
})
if (res.success && res.data) {
const results = res.data.results || res.data
surveyResults.value = results.map((result, i) => {
const agentIdx = interviews[i].agent_id
const agent = profiles.value[agentIdx]
return {
agent_id: agentIdx,
agent_name: agent?.username || `Agent ${agentIdx}`,
profession: agent?.profession,
question: surveyQuestion.value.trim(),
answer: result.response || result.answer || '无响应'
}
})
addLog(`收到 ${surveyResults.value.length} 条回复`)
} else {
throw new Error(res.error || '请求失败')
}
} catch (err) {
addLog(`问卷发送失败: ${err.message}`)
} finally {
isSurveying.value = false
}
}
// Load Report Data
const loadReportData = async () => {
if (!props.reportId) return
try {
addLog(`加载报告数据: ${props.reportId}`)
// Get report info
const reportRes = await getReport(props.reportId)
if (reportRes.success && reportRes.data) {
// Load agent logs to get report outline and sections
await loadAgentLogs()
}
} catch (err) {
addLog(`加载报告失败: ${err.message}`)
}
}
const loadAgentLogs = async () => {
if (!props.reportId) return
try {
const res = await getAgentLog(props.reportId, 0)
if (res.success && res.data) {
const logs = res.data.logs || []
logs.forEach(log => {
if (log.action === 'planning_complete' && log.details?.outline) {
reportOutline.value = log.details.outline
}
if (log.action === 'section_complete' && log.section_index < 100 && log.details?.content) {
generatedSections.value[log.section_index] = log.details.content
}
})
addLog('报告数据加载完成')
}
} catch (err) {
addLog(`加载报告日志失败: ${err.message}`)
}
}
const loadProfiles = async () => {
if (!props.simulationId) return
try {
const res = await getSimulationProfilesRealtime(props.simulationId, 'reddit')
if (res.success && res.data) {
profiles.value = res.data.profiles || []
addLog(`加载了 ${profiles.value.length} 个模拟个体`)
}
} catch (err) {
addLog(`加载模拟个体失败: ${err.message}`)
}
}
// Click outside to close dropdown
const handleClickOutside = (e) => {
const dropdown = document.querySelector('.agent-dropdown')
if (dropdown && !dropdown.contains(e.target)) {
showAgentDropdown.value = false
}
}
// Lifecycle
onMounted(() => {
addLog('Step5 深度互动初始化')
loadReportData()
loadProfiles()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
watch(() => props.reportId, (newId) => {
if (newId) {
loadReportData()
}
}, { immediate: true })
watch(() => props.simulationId, (newId) => {
if (newId) {
loadProfiles()
}
}, { immediate: true })
</script>
<style scoped>
.interaction-panel {
height: 100%;
display: flex;
flex-direction: column;
background: #F8F9FA;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
overflow: hidden;
}
/* Main Split Layout */
.main-split-layout {
flex: 1;
display: flex;
overflow: hidden;
}
/* Left Panel - Report Style (与 Step4Report.vue 完全一致) */
.left-panel.report-style {
width: 45%;
min-width: 450px;
background: #FFFFFF;
border-right: 1px solid #E5E7EB;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 30px 50px 60px 50px;
}
.left-panel::-webkit-scrollbar {
width: 6px;
}
.left-panel::-webkit-scrollbar-track {
background: transparent;
}
.left-panel::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
transition: background 0.3s ease;
}
.left-panel:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
.left-panel::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
/* Report Header */
.report-content-wrapper {
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.report-header-block {
margin-bottom: 50px;
}
.report-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.report-tag {
background: #000000;
color: #FFFFFF;
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.report-id {
font-size: 11px;
color: #9CA3AF;
font-weight: 500;
letter-spacing: 0.02em;
}
.main-title {
font-family: 'Times New Roman', Times, serif;
font-size: 36px;
font-weight: 700;
color: #111827;
line-height: 1.2;
margin: 0 0 16px 0;
letter-spacing: -0.02em;
}
.sub-title {
font-family: 'Times New Roman', Times, serif;
font-size: 16px;
color: #6B7280;
font-style: italic;
line-height: 1.6;
margin: 0 0 30px 0;
font-weight: 400;
}
.header-divider {
height: 1px;
background: #E5E7EB;
width: 100%;
}
/* Sections List */
.sections-list {
display: flex;
flex-direction: column;
gap: 32px;
}
.report-section-item {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-header-row {
display: flex;
align-items: baseline;
gap: 12px;
transition: background-color 0.2s ease;
padding: 8px 12px;
margin: -8px -12px;
border-radius: 8px;
}
.section-header-row.clickable {
cursor: pointer;
}
.section-header-row.clickable:hover {
background-color: #F9FAFB;
}
.collapse-icon {
margin-left: auto;
color: #9CA3AF;
transition: transform 0.3s ease;
flex-shrink: 0;
align-self: center;
}
.collapse-icon.is-collapsed {
transform: rotate(-90deg);
}
.section-number {
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
color: #E5E7EB;
font-weight: 500;
transition: color 0.3s ease;
}
.section-title {
font-family: 'Times New Roman', Times, serif;
font-size: 24px;
font-weight: 600;
color: #111827;
margin: 0;
transition: color 0.3s ease;
}
/* States */
.report-section-item.is-pending .section-number {
color: #E5E7EB;
}
.report-section-item.is-pending .section-title {
color: #D1D5DB;
}
.report-section-item.is-active .section-number,
.report-section-item.is-completed .section-number {
color: #9CA3AF;
}
.report-section-item.is-active .section-title,
.report-section-item.is-completed .section-title {
color: #111827;
}
.section-body {
padding-left: 28px;
overflow: hidden;
}
/* Generated Content */
.generated-content {
font-family: 'Inter', -apple-system, sans-serif;
font-size: 14px;
line-height: 1.8;
color: #374151;
}
.generated-content :deep(p) {
margin-bottom: 1em;
}
.generated-content :deep(.md-h2),
.generated-content :deep(.md-h3),
.generated-content :deep(.md-h4) {
font-family: 'Times New Roman', Times, serif;
color: #111827;
margin-top: 1.5em;
margin-bottom: 0.8em;
font-weight: 700;
}
.generated-content :deep(.md-h2) { font-size: 20px; border-bottom: 1px solid #F3F4F6; padding-bottom: 8px; }
.generated-content :deep(.md-h3) { font-size: 18px; }
.generated-content :deep(.md-h4) { font-size: 16px; }
.generated-content :deep(.md-ul),
.generated-content :deep(.md-ol) {
padding-left: 20px;
margin-bottom: 1em;
}
.generated-content :deep(.md-li) {
margin-bottom: 0.5em;
}
.generated-content :deep(.md-quote) {
border-left: 3px solid #E5E7EB;
padding-left: 16px;
margin: 1.5em 0;
color: #6B7280;
font-style: italic;
font-family: 'Times New Roman', Times, serif;
}
.generated-content :deep(.code-block) {
background: #F9FAFB;
padding: 12px;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
overflow-x: auto;
margin: 1em 0;
border: 1px solid #E5E7EB;
}
.generated-content :deep(strong) {
font-weight: 600;
color: #111827;
}
/* Loading State */
.loading-state {
display: flex;
align-items: center;
gap: 10px;
color: #6B7280;
font-size: 14px;
margin-top: 4px;
}
.loading-icon {
width: 18px;
height: 18px;
animation: spin 1s linear infinite;
display: flex;
align-items: center;
justify-content: center;
}
.loading-text {
font-family: 'Times New Roman', Times, serif;
font-size: 15px;
color: #4B5563;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Content Styles Override */
.generated-content :deep(.md-h2) {
font-family: 'Times New Roman', Times, serif;
font-size: 18px;
margin-top: 0;
}
/* Waiting Placeholder */
.waiting-placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 40px;
color: #9CA3AF;
}
.waiting-animation {
position: relative;
width: 48px;
height: 48px;
}
.waiting-ring {
position: absolute;
width: 100%;
height: 100%;
border: 2px solid #E5E7EB;
border-radius: 50%;
animation: ripple 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.waiting-ring:nth-child(2) {
animation-delay: 0.4s;
}
.waiting-ring:nth-child(3) {
animation-delay: 0.8s;
}
@keyframes ripple {
0% { transform: scale(0.5); opacity: 1; }
100% { transform: scale(1.5); opacity: 0; }
}
.waiting-text {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* Right Panel - Interaction */
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
background: #FFFFFF;
overflow: hidden;
}
/* Interaction Header */
.interaction-header {
padding: 16px 24px;
border-bottom: 1px solid #E5E7EB;
background: #FAFAFA;
}
.tab-switcher {
display: flex;
gap: 8px;
}
.tab-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
color: #6B7280;
background: transparent;
border: 1px solid #E5E7EB;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn:hover {
background: #F9FAFB;
border-color: #D1D5DB;
}
.tab-btn.active {
background: #1F2937;
color: #FFFFFF;
border-color: #1F2937;
}
.tab-btn svg {
flex-shrink: 0;
}
/* Chat Container */
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Target Selector */
.target-selector {
padding: 16px 24px;
border-bottom: 1px solid #E5E7EB;
}
.selector-label {
font-size: 11px;
font-weight: 600;
color: #9CA3AF;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 10px;
}
.selector-options {
display: flex;
gap: 12px;
}
.target-option {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
color: #374151;
background: #F9FAFB;
border: 1px solid #E5E7EB;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.target-option:hover {
border-color: #D1D5DB;
}
.target-option.active {
background: #1F2937;
color: #FFFFFF;
border-color: #1F2937;
}
/* Agent Dropdown */
.agent-dropdown {
position: relative;
}
.agent-option {
min-width: 180px;
justify-content: flex-start;
}
.dropdown-arrow {
margin-left: auto;
transition: transform 0.2s ease;
}
.dropdown-arrow.open {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
max-height: 300px;
overflow-y: auto;
z-index: 100;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background 0.15s ease;
}
.dropdown-item:hover {
background: #F9FAFB;
}
.agent-avatar {
width: 32px;
height: 32px;
background: #1F2937;
color: #FFFFFF;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
}
.agent-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.agent-name {
font-size: 13px;
font-weight: 600;
color: #1F2937;
}
.agent-role {
font-size: 11px;
color: #9CA3AF;
}
/* Chat Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.chat-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: #9CA3AF;
}
.empty-icon {
opacity: 0.3;
}
.empty-text {
font-size: 14px;
text-align: center;
max-width: 280px;
line-height: 1.6;
}
.chat-message {
display: flex;
gap: 12px;
}
.chat-message.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.chat-message.user .message-avatar {
background: #1F2937;
color: #FFFFFF;
}
.chat-message.assistant .message-avatar {
background: #F3F4F6;
color: #374151;
}
.message-content {
max-width: 70%;
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-message.user .message-content {
align-items: flex-end;
}
.message-header {
display: flex;
align-items: center;
gap: 8px;
}
.chat-message.user .message-header {
flex-direction: row-reverse;
}
.sender-name {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.message-time {
font-size: 11px;
color: #9CA3AF;
}
.message-text {
padding: 12px 16px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
}
.chat-message.user .message-text {
background: #1F2937;
color: #FFFFFF;
border-bottom-right-radius: 4px;
}
.chat-message.assistant .message-text {
background: #F3F4F6;
color: #374151;
border-bottom-left-radius: 4px;
}
/* Typing Indicator */
.typing-indicator {
display: flex;
gap: 4px;
padding: 12px 16px;
background: #F3F4F6;
border-radius: 12px;
border-bottom-left-radius: 4px;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: #9CA3AF;
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) { animation-delay: 0s; }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-8px); }
}
/* Chat Input */
.chat-input-area {
padding: 16px 24px;
border-top: 1px solid #E5E7EB;
display: flex;
gap: 12px;
align-items: flex-end;
}
.chat-input {
flex: 1;
padding: 12px 16px;
font-size: 14px;
border: 1px solid #E5E7EB;
border-radius: 8px;
resize: none;
font-family: inherit;
line-height: 1.5;
transition: border-color 0.2s ease;
}
.chat-input:focus {
outline: none;
border-color: #1F2937;
}
.chat-input:disabled {
background: #F9FAFB;
cursor: not-allowed;
}
.send-btn {
width: 44px;
height: 44px;
background: #1F2937;
color: #FFFFFF;
border: none;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.send-btn:hover:not(:disabled) {
background: #374151;
}
.send-btn:disabled {
background: #E5E7EB;
color: #9CA3AF;
cursor: not-allowed;
}
/* Survey Container */
.survey-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.survey-setup {
padding: 24px;
border-bottom: 1px solid #E5E7EB;
}
.setup-section {
margin-bottom: 24px;
}
.setup-section:last-child {
margin-bottom: 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: #374151;
}
.selection-count {
font-size: 12px;
color: #9CA3AF;
}
/* Agents Grid */
.agents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
max-height: 200px;
overflow-y: auto;
padding: 4px;
}
.agent-checkbox {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #F9FAFB;
border: 1px solid #E5E7EB;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.agent-checkbox:hover {
border-color: #D1D5DB;
}
.agent-checkbox.checked {
background: #F0FDF4;
border-color: #10B981;
}
.agent-checkbox input {
display: none;
}
.checkbox-avatar {
width: 28px;
height: 28px;
background: #E5E7EB;
color: #374151;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.agent-checkbox.checked .checkbox-avatar {
background: #10B981;
color: #FFFFFF;
}
.checkbox-info {
flex: 1;
min-width: 0;
}
.checkbox-name {
display: block;
font-size: 12px;
font-weight: 600;
color: #1F2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.checkbox-role {
display: block;
font-size: 10px;
color: #9CA3AF;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.checkbox-indicator {
width: 20px;
height: 20px;
border: 2px solid #E5E7EB;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.2s ease;
}
.agent-checkbox.checked .checkbox-indicator {
background: #10B981;
border-color: #10B981;
color: #FFFFFF;
}
.checkbox-indicator svg {
opacity: 0;
transform: scale(0.5);
transition: all 0.2s ease;
}
.agent-checkbox.checked .checkbox-indicator svg {
opacity: 1;
transform: scale(1);
}
.selection-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.action-link {
font-size: 12px;
color: #6B7280;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.action-link:hover {
color: #1F2937;
text-decoration: underline;
}
.action-divider {
color: #E5E7EB;
}
/* Survey Input */
.survey-input {
width: 100%;
padding: 14px 16px;
font-size: 14px;
border: 1px solid #E5E7EB;
border-radius: 8px;
resize: none;
font-family: inherit;
line-height: 1.5;
transition: border-color 0.2s ease;
}
.survey-input:focus {
outline: none;
border-color: #1F2937;
}
.survey-submit-btn {
width: 100%;
padding: 14px 24px;
font-size: 14px;
font-weight: 600;
color: #FFFFFF;
background: #1F2937;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 20px;
}
.survey-submit-btn:hover:not(:disabled) {
background: #374151;
}
.survey-submit-btn:disabled {
background: #E5E7EB;
color: #9CA3AF;
cursor: not-allowed;
}
.loading-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #FFFFFF;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Survey Results */
.survey-results {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.results-title {
font-size: 14px;
font-weight: 600;
color: #1F2937;
}
.results-count {
font-size: 12px;
color: #9CA3AF;
}
.results-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.result-card {
background: #F9FAFB;
border: 1px solid #E5E7EB;
border-radius: 12px;
padding: 20px;
}
.result-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.result-avatar {
width: 36px;
height: 36px;
background: #1F2937;
color: #FFFFFF;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
}
.result-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.result-name {
font-size: 14px;
font-weight: 600;
color: #1F2937;
}
.result-role {
font-size: 12px;
color: #9CA3AF;
}
.result-question {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 14px;
background: #FFFFFF;
border-radius: 8px;
margin-bottom: 12px;
font-size: 13px;
color: #6B7280;
}
.result-question svg {
flex-shrink: 0;
margin-top: 2px;
}
.result-answer {
font-size: 14px;
line-height: 1.7;
color: #374151;
}
/* Console Logs */
.console-logs {
background: #000;
color: #DDD;
padding: 16px;
font-family: 'JetBrains Mono', monospace;
border-top: 1px solid #222;
flex-shrink: 0;
}
.log-header {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #333;
padding-bottom: 8px;
margin-bottom: 8px;
font-size: 10px;
color: #666;
}
.log-content {
display: flex;
flex-direction: column;
gap: 4px;
height: 80px;
overflow-y: auto;
padding-right: 4px;
}
.log-content::-webkit-scrollbar {
width: 4px;
}
.log-content::-webkit-scrollbar-thumb {
background: #333;
border-radius: 2px;
}
.log-line {
font-size: 11px;
display: flex;
gap: 12px;
line-height: 1.5;
}
.log-time {
color: #555;
min-width: 75px;
}
.log-msg {
color: #BBB;
word-break: break-all;
}
/* Markdown Styles */
:deep(.md-p) {
margin: 0 0 12px 0;
}
:deep(.md-h2) {
font-size: 20px;
font-weight: 700;
color: #1F2937;
margin: 24px 0 12px 0;
}
:deep(.md-h3) {
font-size: 16px;
font-weight: 600;
color: #374151;
margin: 20px 0 10px 0;
}
:deep(.md-h4) {
font-size: 14px;
font-weight: 600;
color: #4B5563;
margin: 16px 0 8px 0;
}
:deep(.md-h5) {
font-size: 13px;
font-weight: 600;
color: #6B7280;
margin: 12px 0 6px 0;
}
:deep(.md-ul), :deep(.md-ol) {
margin: 12px 0;
padding-left: 24px;
}
:deep(.md-li), :deep(.md-oli) {
margin: 6px 0;
}
:deep(.md-quote) {
margin: 12px 0;
padding: 12px 16px;
background: #F9FAFB;
border-left: 3px solid #1F2937;
color: #4B5563;
}
:deep(.code-block) {
margin: 12px 0;
padding: 12px 16px;
background: #1F2937;
border-radius: 6px;
overflow-x: auto;
}
:deep(.code-block code) {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: #E5E7EB;
}
:deep(.inline-code) {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
background: #F3F4F6;
padding: 2px 6px;
border-radius: 4px;
color: #1F2937;
}
:deep(.md-hr) {
border: none;
border-top: 1px solid #E5E7EB;
margin: 24px 0;
}
</style>