refactor: 优化前端文件结构

This commit is contained in:
ViperEkura 2026-04-29 12:48:48 +08:00
parent c36563f968
commit f948dfc45f
7 changed files with 2818 additions and 1144 deletions

View File

@ -0,0 +1,166 @@
<script setup>
// Props
const props = defineProps({
show: {
type: Boolean,
default: false
},
agentPool: {
type: Array,
default: () => []
}
})
// Emits
const emit = defineEmits([
'close',
'add'
])
// Handlers
function handleClose() {
emit('close')
}
function handleAdd(agent) {
emit('add', agent)
handleClose()
}
</script>
<template>
<div v-if="show" class="modal-overlay" @click.self="handleClose">
<div class="modal">
<!-- Header -->
<div class="modal-head">
<h3>添加 Agent 到房间</h3>
<button class="btn-close" @click="handleClose">&times;</button>
</div>
<!-- Body -->
<div class="modal-body">
<div v-if="agentPool.length === 0" class="no-agents-hint">
请先在左侧 Agent 池中创建 Agent
</div>
<div v-else class="agents-list">
<div
v-for="a in agentPool"
:key="a.id"
class="agent-pick-row"
@click="handleAdd(a)"
>
<span class="agent-dot" :style="{ background: a.color }">
{{ a.name.charAt(0) }}
</span>
<span class="agent-checkbox-name" :style="{ color: a.color }">
{{ a.name }}
</span>
<span class="agent-checkbox-model">{{ a.model || 'default' }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-primary);
border-radius: 16px;
width: 400px;
max-width: 95vw;
max-height: 85vh;
overflow-y: auto;
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light);
}
.modal-head h3 {
margin: 0;
font-size: 1rem;
}
.btn-close {
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
line-height: 1;
}
.modal-body {
padding: 1.25rem;
}
.no-agents-hint {
color: var(--text-secondary);
font-size: 0.8rem;
padding: 0.5rem 0;
text-align: center;
}
.agents-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.agent-pick-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.8rem;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.agent-pick-row:hover {
background: var(--bg-secondary);
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.agent-dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.agent-checkbox-name {
font-size: 0.85rem;
font-weight: 600;
flex: 1;
}
.agent-checkbox-model {
font-size: 0.7rem;
color: var(--text-secondary);
}
</style>

View File

@ -0,0 +1,377 @@
<script setup>
import { ref, computed } from 'vue'
// Props
const props = defineProps({
show: {
type: Boolean,
default: false
},
editingAgent: {
type: [Object, String],
default: null // null | 'new' | agent object
},
agentForm: {
type: Object,
default: () => ({})
},
providers: {
type: Array,
default: () => []
},
availableModels: {
type: Array,
default: () => []
},
saving: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits([
'close',
'save',
'delete',
'providerChange',
'update:modelValue'
])
// Computed
const isNew = computed(() => props.editingAgent === 'new')
const title = computed(() => isNew.value ? '新建 Agent' : '编辑 Agent')
const canSave = computed(() => props.agentForm.name && !props.saving)
const canDelete = computed(() => !isNew.value)
// Handlers
function handleClose() {
emit('close')
}
function handleSave() {
if (canSave.value) {
emit('save')
}
}
function handleDelete() {
emit('delete', props.editingAgent)
}
function handleProviderChange() {
emit('providerChange')
}
function handleModelChange(e) {
emit('update:modelValue', e.target.value)
}
// Color picker
function randomColor() {
const colors = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
return colors[Math.floor(Math.random() * colors.length)]
}
</script>
<template>
<div v-if="show" class="modal-overlay" @click.self="handleClose">
<div class="modal">
<!-- Header -->
<div class="modal-head">
<h3>{{ title }}</h3>
<button class="btn-close" @click="handleClose">&times;</button>
</div>
<!-- Body -->
<div class="modal-body">
<!-- Name -->
<div class="fg">
<label>名称</label>
<input
v-model="agentForm.name"
placeholder="如: 架构师"
class="fi"
/>
</div>
<!-- Role -->
<div class="fg">
<label>角色</label>
<input
v-model="agentForm.role"
placeholder="如: 架构师、开发者、审查员"
class="fi"
/>
</div>
<!-- Provider & Color -->
<div class="fg-row">
<div class="fg" style="flex: 1;">
<label>Provider</label>
<select
v-model="agentForm.provider_id"
@change="handleProviderChange"
class="fi"
>
<option :value="null">默认 Provider</option>
<option
v-for="p in providers"
:key="p.id"
:value="p.id"
>
{{ p.name }}
</option>
</select>
</div>
<div class="fg fg-color">
<label>颜色</label>
<div class="color-picker-wrap">
<input
type="color"
v-model="agentForm.color"
class="color-input"
/>
</div>
</div>
</div>
<!-- Model -->
<div class="fg">
<label>模型</label>
<select
:value="agentForm.model"
@change="handleModelChange"
class="fi"
>
<option value="">自动选择</option>
<option
v-for="m in availableModels"
:key="m"
:value="m"
>
{{ m }}
</option>
</select>
</div>
<!-- System Prompt -->
<div class="fg">
<label>系统提示词</label>
<textarea
v-model="agentForm.system_prompt"
rows="3"
placeholder="定义 Agent 的角色和行为..."
class="fi"
></textarea>
</div>
</div>
<!-- Footer -->
<div class="modal-foot">
<button
v-if="canDelete"
class="btn-danger"
@click="handleDelete"
>
删除
</button>
<div style="flex: 1;"></div>
<button class="btn-secondary" @click="handleClose">取消</button>
<button
class="btn-primary"
@click="handleSave"
:disabled="!canSave"
>
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-primary);
border-radius: 16px;
width: 480px;
max-width: 95vw;
max-height: 85vh;
overflow-y: auto;
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light);
}
.modal-head h3 {
margin: 0;
font-size: 1rem;
}
.btn-close {
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
line-height: 1;
}
.modal-body {
padding: 1.25rem;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border-light);
}
.fg {
margin-bottom: 1.25rem;
}
.fg label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fg input,
.fg textarea,
.fg select {
width: 100%;
padding: 0.6rem 0.875rem;
border: 1px solid var(--border-light);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.875rem;
box-sizing: border-box;
transition: all 0.2s ease;
}
.fg input:focus,
.fg textarea:focus,
.fg select:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.fg textarea {
resize: vertical;
min-height: 80px;
}
.fg-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.fg-color {
width: 70px;
flex-shrink: 0;
}
.fg-color label {
margin-bottom: 0.4rem;
display: block;
}
.color-picker-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 4px 10px;
border: 1px solid var(--border-light);
border-radius: 10px;
background: var(--bg-secondary);
transition: all 0.2s ease;
}
.color-picker-wrap:hover {
border-color: var(--accent-primary);
}
.color-input {
width: 32px !important;
height: 32px !important;
padding: 0 !important;
border: none !important;
border-radius: 6px;
cursor: pointer;
background: none;
flex-shrink: 0;
}
/* Buttons */
.btn-primary {
padding: 0.5rem 1rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-primary:hover {
background: var(--accent-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-light);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-danger {
padding: 0.5rem 1rem;
background: var(--danger-bg, rgba(239, 68, 68, 0.1));
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.2);
}
</style>

View File

@ -0,0 +1,381 @@
<script setup>
import { ref, computed } from 'vue'
// Props
const props = defineProps({
show: {
type: Boolean,
default: false
},
agentPool: {
type: Array,
default: () => []
},
creating: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits([
'close',
'create'
])
// Local state
const newRoom = ref({
title: '',
task: '',
max_rounds: 5,
agent_ids: [],
execution_mode: 'sequential'
})
// Computed
const canCreate = computed(() =>
newRoom.value.title &&
newRoom.value.task &&
newRoom.value.agent_ids.length > 0 &&
!props.creating
)
// Watch show to reset
function resetForm() {
newRoom.value = {
title: '',
task: '',
max_rounds: 5,
agent_ids: [],
execution_mode: 'sequential'
}
}
// Handlers
function handleClose() {
emit('close')
resetForm()
}
function handleCreate() {
if (canCreate.value) {
emit('create', { ...newRoom.value })
}
}
function toggleAgent(agentId) {
const idx = newRoom.value.agent_ids.indexOf(agentId)
if (idx >= 0) {
newRoom.value.agent_ids.splice(idx, 1)
} else {
newRoom.value.agent_ids.push(agentId)
}
}
</script>
<template>
<div v-if="show" class="modal-overlay" @click.self="handleClose">
<div class="modal">
<!-- Header -->
<div class="modal-head">
<h3>新建聊天室</h3>
<button class="btn-close" @click="handleClose">&times;</button>
</div>
<!-- Body -->
<div class="modal-body">
<!-- Title -->
<div class="fg">
<label>标题</label>
<input
v-model="newRoom.title"
placeholder="项目架构设计讨论"
/>
</div>
<!-- Task -->
<div class="fg">
<label>任务描述</label>
<textarea
v-model="newRoom.task"
rows="3"
placeholder="描述需要 Agent 讨论的问题..."
></textarea>
</div>
<!-- Max rounds & Execution mode -->
<div class="fg-row">
<div class="fg" style="flex: 1;">
<label>最大轮次</label>
<input
v-model.number="newRoom.max_rounds"
type="number"
min="1"
max="20"
/>
</div>
<div class="fg" style="flex: 1;">
<label>执行模式</label>
<select v-model="newRoom.execution_mode">
<option value="sequential">📋 串行</option>
<option value="parallel"> 并行</option>
<option value="review_loop">🔄 监督循环</option>
</select>
</div>
</div>
<!-- Agent selection -->
<div class="agents-select">
<label class="agents-select-label">选择 Agent</label>
<div v-if="agentPool.length === 0" class="no-agents-hint">
请先在左侧 Agent 池中创建 Agent
</div>
<div v-else class="agents-checkboxes">
<label
v-for="a in agentPool"
:key="a.id"
class="agent-checkbox"
:class="{ checked: newRoom.agent_ids.includes(a.id) }"
>
<input
type="checkbox"
:value="a.id"
v-model="newRoom.agent_ids"
/>
<span class="agent-dot" :style="{ background: a.color }">
{{ a.name.charAt(0) }}
</span>
<span class="agent-checkbox-name" :style="{ color: a.color }">
{{ a.name }}
</span>
<span class="agent-checkbox-model">{{ a.model || 'default' }}</span>
</label>
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-foot">
<button class="btn-secondary" @click="handleClose">取消</button>
<button
class="btn-primary"
@click="handleCreate"
:disabled="!canCreate"
>
{{ creating ? '创建中...' : '创建' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-primary);
border-radius: 16px;
width: 480px;
max-width: 95vw;
max-height: 85vh;
overflow-y: auto;
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light);
}
.modal-head h3 {
margin: 0;
font-size: 1rem;
}
.btn-close {
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
line-height: 1;
}
.modal-body {
padding: 1.25rem;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border-light);
}
.fg {
margin-bottom: 1.25rem;
}
.fg label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fg input,
.fg textarea,
.fg select {
width: 100%;
padding: 0.6rem 0.875rem;
border: 1px solid var(--border-light);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.875rem;
box-sizing: border-box;
transition: all 0.2s ease;
}
.fg input:focus,
.fg textarea:focus,
.fg select:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.fg textarea {
resize: vertical;
min-height: 80px;
}
.fg-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.agents-select {
margin-top: 0.5rem;
}
.agents-select-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
display: block;
margin-bottom: 0.5rem;
}
.no-agents-hint {
color: var(--text-secondary);
font-size: 0.8rem;
padding: 0.5rem 0;
}
.agents-checkboxes {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 200px;
overflow-y: auto;
padding: 0.25rem;
}
.agent-checkbox {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.agent-checkbox:hover {
background: var(--bg-secondary);
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.agent-checkbox.checked {
background: var(--accent-primary-light);
border-color: var(--accent-primary);
}
.agent-checkbox input {
display: none;
}
.agent-dot {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.agent-checkbox-name {
font-size: 0.85rem;
font-weight: 600;
}
.agent-checkbox-model {
font-size: 0.7rem;
color: var(--text-secondary);
margin-left: auto;
}
/* Buttons */
.btn-primary {
padding: 0.5rem 1rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-primary:hover {
background: var(--accent-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-light);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
</style>

View File

@ -0,0 +1,399 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import MessageBubble from '../MessageBubble.vue'
import ParallelMessages from '../ParallelMessages.vue'
// Props
const props = defineProps({
messages: {
type: Array,
default: () => []
},
streamingMessages: {
type: Object,
default: () => ({})
},
streaming: {
type: Boolean,
default: false
},
messagesLoading: {
type: Boolean,
default: false
},
isParallelMode: {
type: Boolean,
default: false
},
roomId: {
type: String,
default: null
}
})
// Refs
const messagesContainer = ref(null)
const isNearBottom = ref(true)
// Group messages by round number
const groupedMessages = computed(() => {
const groups = []
let currentGroup = []
let currentRound = null
for (const msg of props.messages) {
const round = msg.round_number || 0
if (round !== currentRound) {
if (currentGroup.length > 0) {
groups.push(currentGroup)
}
currentGroup = [msg]
currentRound = round
} else {
currentGroup.push(msg)
}
}
if (currentGroup.length > 0) {
groups.push(currentGroup)
}
return groups
})
const hasMessages = computed(() =>
props.messages.length > 0 || Object.keys(props.streamingMessages).length > 0
)
const streamingMessageList = computed(() => Object.values(props.streamingMessages))
// Scroll functions
function scrollToBottom() {
if (!isNearBottom.value) return
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
function checkNearBottom() {
if (!messagesContainer.value) return
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
isNearBottom.value = distanceFromBottom <= 100
}
function handleScroll() {
checkNearBottom()
}
function forceScrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
isNearBottom.value = true
}
})
}
// Watchers
watch(() => props.messages, () => {
nextTick(scrollToBottom)
}, { deep: true })
watch(() => props.streaming, (val) => {
if (val) forceScrollToBottom()
})
// Expose methods for parent
defineExpose({
forceScrollToBottom
})
</script>
<template>
<div
class="chat-messages"
ref="messagesContainer"
@scroll="handleScroll"
>
<!-- Parallel mode streaming view -->
<ParallelMessages
v-if="isParallelMode && streaming"
:room-id="roomId"
mode="parallel"
/>
<!-- Parallel mode history messages (card layout) -->
<template v-else-if="isParallelMode">
<div v-if="messagesLoading" class="loading-messages">
<div class="spinner-small"></div>
<span>加载中...</span>
</div>
<div v-else-if="messages.length === 0" class="chat-empty">
<p>点击开始启动多 Agent 对话</p>
</div>
<div v-else class="parallel-history">
<div v-for="(roundMsgs, rIdx) in groupedMessages" :key="rIdx" class="parallel-round">
<div class="round-divider">
<span class="round-divider-line"></span>
<span class="round-divider-label"> {{ roundMsgs[0].round_number || rIdx + 1 }} </span>
<span class="round-divider-line"></span>
</div>
<div class="parallel-grid">
<div
v-for="msg in roundMsgs"
:key="msg.id"
class="parallel-card completed"
:style="{ borderColor: msg.sender_color }"
>
<div class="card-header">
<span class="agent-avatar" :style="{ background: msg.sender_color || '#2563eb' }">
{{ (msg.sender_name || '?').charAt(0) }}
</span>
<span class="agent-name" :style="{ color: msg.sender_color }">{{ msg.sender_name }}</span>
</div>
<div class="card-body">
<MessageBubble :message="msg" :deletable="false" :compact="true" />
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Sequential mode -->
<template v-else>
<div v-if="messagesLoading" class="loading-messages">
<div class="spinner-small"></div>
<span>加载中...</span>
</div>
<div
v-else-if="messages.length === 0 && Object.keys(streamingMessages).length === 0"
class="chat-empty"
>
<p>点击开始启动多 Agent 对话</p>
</div>
<div v-else>
<div v-for="(roundMsgs, rIdx) in groupedMessages" :key="rIdx">
<div class="round-divider" v-if="rIdx > 0">
<span class="round-divider-line"></span>
<span class="round-divider-label"> {{ roundMsgs[0].round_number }} </span>
<span class="round-divider-line"></span>
</div>
<div class="round-group">
<div class="round-header" v-if="roundMsgs[0].round_number">
<span class="round-header-icon">💬</span>
<span class="round-header-text"> {{ roundMsgs[0].round_number }} 轮对话</span>
</div>
<MessageBubble
v-for="msg in roundMsgs"
:key="msg.id"
:message="msg"
:deletable="false"
/>
</div>
</div>
<!-- Streaming messages -->
<template v-if="Object.keys(streamingMessages).length > 0">
<div class="round-divider" v-if="messages.length > 0">
<span class="round-divider-line"></span>
<span class="round-divider-label">进行中...</span>
<span class="round-divider-line"></span>
</div>
<div class="round-group">
<MessageBubble
v-for="(msg, idx) in streamingMessageList"
:key="msg.id + '-' + idx"
:message="msg"
:deletable="false"
class="streaming-message"
/>
</div>
</template>
</div>
</template>
</div>
</template>
<style scoped>
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
}
.chat-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 0.85rem;
height: 100%;
}
.loading-messages {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.85rem;
}
.spinner-small {
width: 24px;
height: 24px;
border: 3px solid var(--border-light);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 0.5rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Round group styling */
.round-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.round-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
margin-bottom: 0.25rem;
font-size: 0.75rem;
color: var(--text-secondary);
font-weight: 600;
}
.round-header-icon {
font-size: 0.85rem;
}
.round-header-text {
opacity: 0.8;
}
.round-divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.25rem 0;
padding: 0 0.5rem;
}
.round-divider-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-light), transparent);
}
.round-divider-label {
font-size: 0.7rem;
color: var(--text-tertiary);
background: var(--bg-secondary);
padding: 0.2rem 0.6rem;
border-radius: 10px;
border: 1px solid var(--border-light);
font-weight: 600;
white-space: nowrap;
}
/* Parallel history messages */
.parallel-history {
padding: 0.5rem 0;
}
.parallel-round {
margin-bottom: 1rem;
}
.parallel-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
padding: 0.5rem 0;
}
.parallel-card {
background: var(--bg-primary);
border: 2px solid var(--border-light);
border-radius: 12px;
overflow: hidden;
transition: all 0.2s ease;
}
.parallel-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.parallel-card .card-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-light);
}
.parallel-card .agent-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 13px;
}
.parallel-card .agent-name {
font-weight: 600;
font-size: 0.85rem;
}
.parallel-card .card-body {
padding: 12px;
max-height: 300px;
overflow-y: auto;
}
/* Streaming message animation */
.streaming-message {
opacity: 0.85;
animation: streamingPulse 2s ease-in-out infinite;
}
@keyframes streamingPulse {
0%, 100% {
opacity: 0.85;
}
50% {
opacity: 0.7;
}
}
.streaming-message :deep(.avatar) {
animation: avatarPulse 1.5s ease-in-out infinite;
}
@keyframes avatarPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
</style>

View File

@ -0,0 +1,694 @@
<script setup>
import { ref, computed } from 'vue'
import { agentsAPI } from '../../utils/api.js'
// Props
const props = defineProps({
agentPool: {
type: Array,
default: () => []
},
rooms: {
type: Array,
default: () => []
},
selectedId: {
type: String,
default: null
},
room: {
type: Object,
default: null
},
canEditRoom: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits([
'selectRoom',
'createRoom',
'deleteRoom',
'createAgent',
'editAgent',
'deleteAgent',
'showAddToRoom'
])
// Sidebar tab state
const sidebarTab = ref('rooms') // 'agents' | 'rooms' | 'roomAgents'
// Status map
const statusMap = {
idle: { label: '就绪', class: 'status-idle' },
running: { label: '进行中', class: 'status-running' },
paused: { label: '已暂停', class: 'status-paused' },
completed: { label: '已完成', class: 'status-completed' },
error: { label: '错误', class: 'status-error' }
}
// Computed
const roomAgents = computed(() => props.room?.agents || [])
const canEdit = computed(() => props.canEditRoom)
// Handlers
function handleSelectRoom(id) {
emit('selectRoom', id)
}
function handleDeleteRoom(e, id) {
e.stopPropagation()
emit('deleteRoom', id)
}
function handleCreateRoom() {
emit('createRoom')
}
function handleCreateAgent() {
emit('createAgent')
}
function handleEditAgent(agent) {
emit('editAgent', agent)
}
function handleDeleteAgent(agent) {
emit('deleteAgent', agent)
}
function handleShowAddToRoom() {
emit('showAddToRoom')
}
function handleBack() {
emit('selectRoom', null)
}
</script>
<template>
<aside class="rooms-sidebar">
<!-- Tab bar -->
<div class="sidebar-tabs">
<button
class="tab-btn"
:class="{ active: sidebarTab === 'rooms' }"
@click="sidebarTab = 'rooms'"
>
💬 聊天室
</button>
<button
class="tab-btn"
:class="{ active: sidebarTab === 'agents' }"
@click="sidebarTab = 'agents'"
>
🤖 Agent
</button>
</div>
<!-- Agent Pool tab -->
<div v-if="sidebarTab === 'agents'" class="sidebar-tab-content">
<div class="sidebar-header">
<button class="btn-new-conv" @click="handleCreateAgent">+ 新建 Agent</button>
</div>
<div v-if="loading" class="sidebar-loading">
<div class="spinner-small"></div>
</div>
<div v-else-if="agentPool.length === 0" class="sidebar-empty">暂无 Agent</div>
<div v-else class="pool-list">
<div
v-for="agent in agentPool"
:key="agent.id"
class="pool-item"
@click="handleEditAgent(agent)"
>
<span class="agent-dot" :style="{ background: agent.color }">
{{ agent.name.charAt(0) }}
</span>
<div class="agent-info">
<span class="agent-name" :style="{ color: agent.color }">{{ agent.name }}</span>
<span v-if="agent.role" class="agent-role">{{ agent.role }}</span>
<span class="agent-model">{{ agent.model || 'default' }}</span>
</div>
</div>
</div>
</div>
<!-- Room list tab -->
<div v-if="sidebarTab === 'rooms'" class="sidebar-tab-content">
<div class="sidebar-header">
<button class="btn-new-conv" @click="handleCreateRoom">+ 新建聊天室</button>
</div>
<div v-if="loading" class="sidebar-loading">
<div class="spinner-small"></div>
</div>
<div v-else-if="rooms.length === 0" class="sidebar-empty">暂无聊天室</div>
<div v-else class="room-list">
<div
v-for="r in rooms"
:key="r.id"
class="room-item"
:class="{ active: selectedId === r.id }"
@click="handleSelectRoom(r.id)"
>
<div class="room-item-header">
<span class="room-item-title">{{ r.title }}</span>
<span
class="status-dot"
:class="statusMap[r.status]?.class"
:title="statusMap[r.status]?.label"
></span>
</div>
<div class="room-item-meta">
<span class="meta-text">
<span
v-for="a in (r.agents || []).slice(0, 4)"
:key="a.id"
class="mini-dot"
:style="{ background: a.color }"
:title="a.name"
></span>
{{ r.agents?.length || 0 }} Agents
</span>
<span class="meta-text">{{ r.current_round }}/{{ r.max_rounds }}</span>
<div class="room-item-actions" @click.stop>
<button @click="(e) => handleDeleteRoom(e, r.id)" class="btn-icon-sm" title="删除">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Room agents tab (shown when a room is selected) -->
<div v-if="sidebarTab === 'roomAgents' && room" class="sidebar-tab-content">
<div class="sidebar-header sidebar-header-row">
<button class="btn-back" @click="handleBack">
<svg width="18" height="18">
<use href="#arrow-left-icon"/>
</svg>
</button>
<span class="sidebar-title">{{ room.title }}</span>
<button
v-if="canEdit"
class="btn-add-agent-sm"
@click="handleShowAddToRoom"
title="添加 Agent"
>
+
</button>
</div>
<div class="room-agent-list">
<div
v-for="agent in roomAgents"
:key="agent.id"
class="agent-row"
:class="{ editing: false }"
@click="$emit('editRoomAgent', agent)"
>
<span class="agent-dot" :style="{ background: agent.color }">
{{ agent.name.charAt(0) }}
</span>
<div class="agent-info">
<span class="agent-name" :style="{ color: agent.color }">{{ agent.name }}</span>
<span v-if="agent.role" class="agent-role">{{ agent.role }}</span>
<span class="agent-model">{{ agent.model || 'default' }}</span>
</div>
<button
v-if="canEdit"
class="btn-del-agent"
@click.stop="$emit('deleteRoomAgent', agent)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div v-if="roomAgents.length === 0" class="no-agents">
<p>暂无 Agent</p>
</div>
</div>
<!-- Room agent edit form (inline) -->
<slot name="agentEditForm"></slot>
<!-- Room info -->
<div class="sidebar-room-info">
<div class="info-item">
<span class="info-label">任务</span>
<p class="info-value">{{ room.task }}</p>
</div>
<div class="info-item">
<span class="info-label">轮次</span>
<p class="info-value">{{ room.max_rounds }}</p>
</div>
</div>
</div>
</aside>
</template>
<style scoped>
/* Sidebar container */
.rooms-sidebar {
width: 100%;
height: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Tab bar */
.sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
background: var(--bg-secondary);
}
.tab-btn {
flex: 1;
min-width: 0;
padding: 0.6rem 0.4rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
font-size: 0.72rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-btn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.tab-btn.active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
background: var(--accent-primary-light);
}
/* Tab content */
.sidebar-tab-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Scrollable lists */
.pool-list,
.room-list,
.room-agent-list {
flex: 1;
overflow-y: auto;
}
.pool-list {
padding: 0.3rem 0.5rem;
}
.room-agent-list {
padding: 0.3rem 0.5rem;
}
/* Header */
.sidebar-header {
padding: 0.75rem;
border-bottom: 1px solid var(--border-light);
}
.sidebar-header-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-back {
background: var(--accent-primary);
border: 1px solid var(--accent-primary);
color: white;
cursor: pointer;
padding: 0.35rem;
margin-right: 0.5rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s;
flex-shrink: 0;
}
.btn-back:hover {
background: var(--accent-primary-hover);
border-color: var(--accent-primary-hover);
}
.btn-back svg {
width: 18px;
height: 18px;
}
.sidebar-title {
font-size: 0.8rem;
font-weight: 700;
color: var(--text-primary);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-add-agent-sm {
width: 26px;
height: 26px;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
.btn-add-agent-sm:hover {
background: var(--accent-primary-hover);
}
.btn-new-conv {
width: 100%;
padding: 0.5rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-new-conv:hover {
background: var(--accent-primary-hover);
}
.sidebar-loading,
.sidebar-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
color: var(--text-secondary);
font-size: 0.8rem;
}
/* Pool item */
.pool-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.6rem;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.pool-item:hover {
background: var(--bg-secondary);
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* Room item */
.room-item {
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: all 0.15s;
}
.room-item:hover {
background: var(--bg-hover);
}
.room-item.active {
background: var(--accent-primary-light);
border-left: 3px solid var(--accent-primary);
}
.room-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.2rem;
}
.room-item-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-idle {
background: var(--text-secondary);
}
.status-running {
background: #22c55e;
animation: pulse 1.5s ease-in-out infinite;
}
.status-paused {
background: #f59e0b;
}
.status-completed {
background: #3b82f6;
}
.status-error {
background: #ef4444;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.room-item-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.7rem;
color: var(--text-secondary);
}
.meta-text {
display: flex;
align-items: center;
gap: 3px;
}
.mini-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.room-item-actions {
margin-left: auto;
}
.btn-icon-sm {
background: transparent;
border: none;
cursor: pointer;
padding: 2px;
border-radius: 3px;
color: var(--text-secondary);
transition: all 0.15s;
display: flex;
align-items: center;
}
.btn-icon-sm:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
/* Agent row */
.agent-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.6rem;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.agent-row:hover {
background: var(--bg-secondary);
border-color: var(--border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.agent-row:hover .agent-dot {
transform: scale(1.1);
}
.agent-dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: white;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: transform 0.2s ease;
}
.agent-info {
flex: 1;
min-width: 0;
}
.agent-name {
font-size: 0.8rem;
font-weight: 600;
display: block;
}
.agent-role {
font-size: 0.65rem;
color: var(--text-secondary);
display: block;
margin-top: 1px;
}
.agent-model {
font-size: 0.6rem;
color: var(--text-secondary);
display: block;
margin-top: 1px;
opacity: 0.7;
}
.btn-del-agent {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 2px;
opacity: 0;
transition: all 0.15s;
}
.agent-row:hover .btn-del-agent {
opacity: 1;
}
.btn-del-agent:hover {
color: #ef4444;
}
.no-agents {
text-align: center;
padding: 0.8rem 0;
color: var(--text-secondary);
font-size: 0.75rem;
}
.no-agents p {
margin: 0;
}
/* Sidebar room info */
.sidebar-room-info {
border-top: 1px solid var(--border-light);
padding: 0.5rem 0.6rem;
flex-shrink: 0;
}
.info-item {
margin-bottom: 0.2rem;
}
.info-label {
font-size: 0.6rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
font-size: 0.75rem;
color: var(--text-primary);
margin: 0.1rem 0 0;
line-height: 1.4;
}
/* Spinner */
.spinner-small {
width: 24px;
height: 24px;
border: 3px solid var(--border-light);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 0.5rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,423 @@
<script setup>
import { computed } from 'vue'
// Props
const props = defineProps({
room: {
type: Object,
default: null
},
streaming: {
type: Boolean,
default: false
},
canEditRoom: {
type: Boolean,
default: false
},
isParallelMode: {
type: Boolean,
default: false
},
parallelStats: {
type: Object,
default: () => ({ total: 0, completed: 0, streaming: 0, error: 0 })
},
parallelAgentList: {
type: Array,
default: () => []
}
})
// Emits
const emit = defineEmits([
'start',
'stop',
'reset',
'updateExecutionMode'
])
// Status map
const statusMap = {
idle: { label: '就绪', class: 'status-idle' },
running: { label: '进行中', class: 'status-running' },
paused: { label: '已暂停', class: 'status-paused' },
completed: { label: '已完成', class: 'status-completed' },
error: { label: '错误', class: 'status-error' }
}
// Computed
const executionMode = computed(() => props.room?.execution_mode || 'sequential')
const modeLabel = computed(() => {
const labels = {
sequential: '📋 Sequential',
parallel: '⚡ Parallel',
review_loop: '🔄 Review Loop'
}
return labels[executionMode.value] || executionMode.value
})
const modeClass = computed(() => {
const classes = {
sequential: 'sequential',
parallel: 'parallel',
review_loop: 'review-loop'
}
return classes[executionMode.value] || 'sequential'
})
const showStart = computed(() => !props.streaming && props.room?.status !== 'running')
const showStop = computed(() => props.streaming || props.room?.status === 'running')
const showReset = computed(() => props.room?.status !== 'running' && (props.room?.message_count || 0) > 0)
// Handlers
function handleStart() {
emit('start')
}
function handleStop() {
emit('stop')
}
function handleReset() {
emit('reset')
}
function handleExecutionModeChange(e) {
emit('updateExecutionMode', e.target.value)
}
</script>
<template>
<div class="room-toolbar">
<!-- Room info -->
<div class="toolbar-info">
<h3>{{ room?.title || '聊天室' }}</h3>
<div class="toolbar-badges">
<span
v-if="room"
class="status-badge"
:class="statusMap[room.status]?.class"
>
{{ statusMap[room.status]?.label }}
</span>
<span
v-if="room?.current_round > 0"
class="round-badge"
>
R{{ room.current_round }}/{{ room.max_rounds }}
</span>
<span
v-if="isParallelMode"
class="mode-badge parallel"
>
Parallel
</span>
<span
v-else-if="executionMode === 'review_loop'"
class="mode-badge review-loop"
>
🔄 Review Loop
</span>
<span v-else class="mode-badge sequential">
📋 Sequential
</span>
</div>
</div>
<!-- Execution mode selector -->
<div class="mode-selector">
<select
v-if="room"
:value="executionMode"
@change="handleExecutionModeChange"
class="mode-select"
:disabled="!canEditRoom"
>
<option value="sequential">📋 Sequential</option>
<option value="parallel"> Parallel</option>
<option value="review_loop">🔄 Review Loop</option>
</select>
</div>
<!-- Parallel status indicator -->
<div
v-if="isParallelMode && streaming && parallelAgentList.length > 0"
class="parallel-status"
>
<div class="agent-dots">
<span
v-for="agent in parallelAgentList"
:key="agent.id"
class="status-dot-small"
:class="agent.status"
:style="{ background: agent.message?.sender_color || agent.color || '#2563eb' }"
:title="agent.name + ': ' + agent.status"
></span>
</div>
<span class="progress-text">
{{ parallelStats.completed }}/{{ parallelStats.total }}
</span>
</div>
<!-- Control buttons -->
<div class="toolbar-actions">
<!-- Start button -->
<button
v-if="showStart"
class="btn-ctrl btn-start"
@click="handleStart"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
{{ room?.status === 'paused' ? '继续' : '开始' }}
</button>
<!-- Stop button -->
<button
v-if="showStop"
class="btn-ctrl btn-stop"
@click="handleStop"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16"></rect>
<rect x="14" y="4" width="4" height="16"></rect>
</svg>
停止
</button>
<!-- Reset button -->
<button
v-if="showReset"
class="btn-ctrl btn-reset"
@click="handleReset"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
重置
</button>
</div>
</div>
</template>
<style scoped>
.room-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.toolbar-info {
flex: 1;
min-width: 0;
}
.toolbar-info h3 {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toolbar-badges {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 2px;
}
.status-badge {
font-size: 0.65rem;
padding: 0.1rem 0.4rem;
border-radius: 8px;
font-weight: 600;
}
.round-badge {
font-size: 0.65rem;
color: var(--text-secondary);
}
.mode-badge {
font-size: 0.6rem;
padding: 0.1rem 0.4rem;
border-radius: 8px;
font-weight: 600;
}
.mode-badge.parallel {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.mode-badge.sequential {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
.mode-badge.review-loop {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
/* Status colors */
.status-idle {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
.status-running {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.status-paused {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.status-completed {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.status-error {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
/* Mode selector */
.mode-selector {
margin-left: auto;
}
.mode-select {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--border-light);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
}
.mode-select:disabled {
opacity: 0.7;
cursor: not-allowed;
background: var(--bg-secondary);
}
/* Parallel status */
.parallel-status {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: rgba(59, 130, 246, 0.1);
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.agent-dots {
display: flex;
gap: 4px;
}
.status-dot-small {
width: 12px;
height: 12px;
border-radius: 50%;
transition: all 0.3s ease;
}
.status-dot-small.streaming {
animation: pulse 1.5s ease-in-out infinite;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
}
.status-dot-small.completed {
opacity: 1;
}
.status-dot-small.error {
opacity: 0.6;
}
.status-dot-small.pending {
opacity: 0.4;
}
.progress-text {
font-size: 0.7rem;
font-weight: 600;
color: #3b82f6;
white-space: nowrap;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Toolbar actions */
.toolbar-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.btn-ctrl {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.75rem;
border: none;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
font-weight: 500;
}
.btn-start {
background: #22c55e;
color: white;
}
.btn-start:hover {
background: #16a34a;
}
.btn-stop {
background: #ef4444;
color: white;
}
.btn-stop:hover {
background: #dc2626;
}
.btn-reset {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-light);
}
.btn-reset:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
</style>

File diff suppressed because it is too large Load Diff