fix: 去除多agent 部分

This commit is contained in:
ViperEkura 2026-05-01 20:09:33 +08:00
parent 9bca0c690f
commit 43bb22667c
24 changed files with 70 additions and 6309 deletions

View File

@ -32,10 +32,6 @@ const navItems = [
path: '/conversations', path: '/conversations',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>` icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>`
}, },
{
path: '/chat-rooms',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>`
},
{ {
path: '/tools', path: '/tools',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>` icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>`

File diff suppressed because it is too large Load Diff

View File

@ -1,166 +0,0 @@
<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

@ -1,377 +0,0 @@
<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

@ -1,381 +0,0 @@
<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

@ -1,399 +0,0 @@
<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

@ -1,695 +0,0 @@
<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" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</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

@ -1,423 +0,0 @@
<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>

View File

@ -32,12 +32,6 @@ const routes = [
component: () => import('../views/ToolsView.vue'), component: () => import('../views/ToolsView.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/chat-rooms',
name: 'ChatRooms',
component: () => import('../views/ChatRoomView.vue'),
meta: { requiresAuth: true }
},
// 首页重定向 // 首页重定向
{ {
path: '/home', path: '/home',

View File

@ -91,32 +91,4 @@ export const providersAPI = {
test: (id) => api.post(`/providers/${id}/test`) test: (id) => api.post(`/providers/${id}/test`)
} }
// ============ Agent 接口 ============
export const agentsAPI = {
list: () => api.get('/agents/'),
create: (data) => api.post('/agents/', data),
get: (id) => api.get(`/agents/${id}`),
update: (id, data) => api.put(`/agents/${id}`, data),
delete: (id) => api.delete(`/agents/${id}`)
}
// ============ 聊天室接口 ============
export const chatRoomsAPI = {
list: (params) => api.get('/chat-rooms/', { params }),
create: (data) => api.post('/chat-rooms/', data),
get: (id) => api.get(`/chat-rooms/${id}`),
update: (id, data) => api.put(`/chat-rooms/${id}`, data),
delete: (id) => api.delete(`/chat-rooms/${id}`),
getMessages: (id) => api.get(`/chat-rooms/${id}/messages`),
start: (id) => `/api/chat-rooms/${id}/start`,
// 注意: start 返回路径字符串,由调用方使用 fetch 处理 SSE 流
stop: (id) => api.post(`/chat-rooms/${id}/stop`),
reset: (id) => api.post(`/chat-rooms/${id}/reset`),
addAgent: (roomId, data) => api.post(`/chat-rooms/${roomId}/agents`, data),
updateAgent: (roomId, agentId, data) => api.put(`/chat-rooms/${roomId}/agents/${agentId}`, data),
deleteAgent: (roomId, agentId) => api.delete(`/chat-rooms/${roomId}/agents/${agentId}`)
}
export default api export default api

View File

@ -1,183 +0,0 @@
import { useParallelStreamStore } from './parallelStreamStore.js'
/**
* Parallel stream manager for handling multi-agent parallel chat room SSE connections.
*/
class ParallelStreamManager {
constructor() {
this.activeRooms = {} // roomId -> { controller, streams }
this.decoder = new TextDecoder()
}
/**
* Start parallel room stream.
* @param {string} roomId - Room identifier
* @param {string} token - Auth token
* @returns {Promise<void>}
*/
async startParallelRoom(roomId, token) {
const controller = new AbortController()
this.activeRooms[roomId] = {
controller,
streams: new Map() // agentId -> stream info
}
const store = useParallelStreamStore()
try {
const response = await fetch(`/api/chat-rooms/${roomId}/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
signal: controller.signal
})
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`)
}
const reader = response.body.getReader()
await this._processStream(roomId, reader, store)
} catch (e) {
if (e.name !== 'AbortError') {
console.error('Parallel stream error:', e)
throw e
}
} finally {
delete this.activeRooms[roomId]
}
}
/**
* Process SSE stream from server.
* @param {string} roomId
* @param {ReadableStreamReader} reader
* @param {Object} store - Pinia store
*/
async _processStream(roomId, reader, store) {
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += this.decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
this._handleParallelEvent(roomId, currentEvent, data, store)
} catch (e) {
console.error('Parse error:', e)
}
}
}
}
}
/**
* Handle parallel event from SSE.
* @param {string} roomId
* @param {string} eventType
* @param {Object} data
* @param {Object} store - Pinia store
*/
_handleParallelEvent(roomId, eventType, data, store) {
switch (eventType) {
case 'parallel_start':
store.initRoom(roomId, data.agents || [], 'parallel')
if (data.round) {
store.updateRoundInfo(roomId, data.round, data.max_rounds || 0)
}
break
case 'round_start':
store.updateRoundInfo(roomId, data.round, data.max_rounds || 0)
break
case 'agent_status':
store.updateAgentStatus(roomId, data.agent_id, data.status)
break
case 'message_start':
store.startAgentStream(roomId, data.agent_id, data)
break
case 'message_chunk':
store.updateAgentContent(roomId, data.agent_id, {
content: data.content || '',
progress: data.progress || 0
})
break
case 'message_end':
store.completeAgentStream(roomId, data.agent_id, data)
break
case 'agent_error':
store.errorAgentStream(roomId, data.agent_id, {
message: data.error,
agentName: data.agent_name
})
break
case 'parallel_end':
// Parallel round completed
break
case 'round_end':
// Round completed
break
case 'room_completed':
store.cleanupRoom(roomId)
break
case 'error':
console.error('Room error:', data.content)
store.cleanupRoom(roomId)
break
}
}
/**
* Cancel room stream.
* @param {string} roomId
*/
cancelRoom(roomId) {
const room = this.activeRooms[roomId]
if (room) {
room.controller.abort()
delete this.activeRooms[roomId]
}
}
/**
* Cancel all active room streams.
*/
cancelAll() {
for (const roomId of Object.keys(this.activeRooms)) {
this.cancelRoom(roomId)
}
}
/**
* Check if a room is currently streaming.
* @param {string} roomId
* @returns {boolean}
*/
isStreaming(roomId) {
return roomId in this.activeRooms
}
}
// Export singleton instance
export const parallelStreamManager = new ParallelStreamManager()

View File

@ -1,168 +0,0 @@
import { defineStore } from 'pinia'
/**
* Parallel stream store for managing multi-agent parallel chat room state.
*/
export const useParallelStreamStore = defineStore('parallelStream', {
state: () => ({
// Per room ID storage for parallel stream state
rooms: {}
}),
actions: {
/**
* Initialize a room for parallel execution.
* @param {string} roomId - Room identifier
* @param {Array} agents - List of agents with id and name
* @param {string} mode - Execution mode ('parallel' or 'sequential')
*/
initRoom(roomId, agents, mode = 'sequential') {
this.rooms[roomId] = {
mode,
agents: {},
roundInfo: { current: 0, max: 0 }
}
agents.forEach(agent => {
this.rooms[roomId].agents[agent.id] = {
id: agent.id,
name: agent.name,
status: 'pending', // pending, streaming, completed, error
message: null,
progress: 0,
error: null
}
})
},
/**
* Update round information.
* @param {string} roomId
* @param {number} current
* @param {number} max
*/
updateRoundInfo(roomId, current, max) {
const room = this.rooms[roomId]
if (room) {
room.roundInfo = { current, max }
}
},
/**
* Start streaming for an agent.
* @param {string} roomId
* @param {string|number} agentId
* @param {Object} messageStart - Initial message data
*/
startAgentStream(roomId, agentId, messageStart) {
const room = this.rooms[roomId]
if (!room) return
const agentIdStr = String(agentId)
room.agents[agentIdStr] = {
...room.agents[agentIdStr],
status: 'streaming',
message: {
id: messageStart.id,
sender_name: messageStart.sender_name,
sender_color: messageStart.sender_color,
content: '',
process_steps: []
},
progress: 0
}
},
/**
* Update agent content with a chunk.
* @param {string} roomId
* @param {string|number} agentId
* @param {Object} chunk - Chunk data with content and progress
*/
updateAgentContent(roomId, agentId, chunk) {
const room = this.rooms[roomId]
if (!room) return
const agentIdStr = String(agentId)
const agent = room.agents[agentIdStr]
if (!agent || agent.status !== 'streaming') return
if (agent.message) {
agent.message.content += chunk.content
}
agent.progress = chunk.progress || 0
},
/**
* Complete agent stream with final message.
* @param {string} roomId
* @param {string|number} agentId
* @param {Object} finalMessage - Complete message data
*/
completeAgentStream(roomId, agentId, finalMessage) {
const room = this.rooms[roomId]
if (!room) return
const agentIdStr = String(agentId)
const agent = room.agents[agentIdStr]
if (!agent) return
agent.status = 'completed'
if (finalMessage) {
agent.message = finalMessage
}
agent.progress = 100
},
/**
* Mark agent as error.
* @param {string} roomId
* @param {string|number} agentId
* @param {string} error - Error message
*/
errorAgentStream(roomId, agentId, error) {
const room = this.rooms[roomId]
if (!room) return
const agentIdStr = String(agentId)
const agent = room.agents[agentIdStr]
if (!agent) return
agent.status = 'error'
agent.error = error
},
/**
* Update agent status.
* @param {string} roomId
* @param {string|number} agentId
* @param {string} status
*/
updateAgentStatus(roomId, agentId, status) {
const room = this.rooms[roomId]
if (!room) return
const agentIdStr = String(agentId)
const agent = room.agents[agentIdStr]
if (agent) {
agent.status = status
}
},
/**
* Clean up room data.
* @param {string} roomId
*/
cleanupRoom(roomId) {
if (this.rooms[roomId]) {
delete this.rooms[roomId]
}
},
/**
* Reset all room data.
*/
resetAll() {
this.rooms = {}
}
}
})

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan manager""" """Application lifespan manager"""
# Import all models to ensure they are registered with Base # Import all models to ensure they are registered with Base
from luxx.models import User, Conversation, Message, Project, LLMProvider, ChatRoom, RoomAgent # noqa from luxx.models import User, Conversation, Message, Project, LLMProvider # noqa
init_db() init_db()
# Create default admin user if not exists, using config values # Create default admin user if not exists, using config values

View File

@ -171,29 +171,18 @@ class Conversation(Base):
class Message(Base): class Message(Base):
"""Message model """Message model for conversations"""
同时服务于普通会话和聊天室
- 普通会话conversation_id 非空room_id 为空
- 聊天室room_id 非空conversation_id 为空sender_name/sender_color/round_number 有值
"""
__tablename__ = "messages" __tablename__ = "messages"
id: Mapped[str] = mapped_column(String(64), primary_key=True) id: Mapped[str] = mapped_column(String(64), primary_key=True)
conversation_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=True) conversation_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=True)
room_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("chat_rooms.id"), nullable=True)
role: Mapped[str] = mapped_column(String(16), nullable=False) role: Mapped[str] = mapped_column(String(16), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False, default="") content: Mapped[str] = mapped_column(Text, nullable=False, default="")
token_count: Mapped[int] = mapped_column(Integer, default=0) token_count: Mapped[int] = mapped_column(Integer, default=0)
usage: Mapped[Optional[str]] = mapped_column(Text, nullable=True) usage: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# 聊天室专属字段(普通会话为空)
sender_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
sender_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True, default="#2563eb")
round_number: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now) created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
conversation: Mapped[Optional["Conversation"]] = relationship("Conversation", back_populates="messages") conversation: Mapped[Optional["Conversation"]] = relationship("Conversation", back_populates="messages")
room: Mapped[Optional["ChatRoom"]] = relationship("ChatRoom", back_populates="messages")
def to_dict(self): def to_dict(self):
import json import json
@ -201,7 +190,6 @@ class Message(Base):
result = { result = {
"id": self.id, "id": self.id,
"conversation_id": self.conversation_id, "conversation_id": self.conversation_id,
"room_id": self.room_id,
"role": self.role, "role": self.role,
"token_count": self.token_count, "token_count": self.token_count,
"created_at": self.created_at.isoformat() if self.created_at else None "created_at": self.created_at.isoformat() if self.created_at else None
@ -213,14 +201,6 @@ class Message(Base):
except json.JSONDecodeError: except json.JSONDecodeError:
result["usage"] = None result["usage"] = None
# 聊天室专属字段
if self.sender_name:
result["sender_name"] = self.sender_name
if self.sender_color:
result["sender_color"] = self.sender_color
if self.round_number is not None:
result["round_number"] = self.round_number
try: try:
content_obj = json.loads(self.content) if self.content else {} content_obj = json.loads(self.content) if self.content else {}
except json.JSONDecodeError: except json.JSONDecodeError:
@ -242,117 +222,3 @@ class Message(Base):
result["attachments"] = content_obj.get("attachments", []) result["attachments"] = content_obj.get("attachments", [])
return result return result
# ============ Chat Room Models ============
class Agent(Base):
"""Standalone reusable Agent template"""
__tablename__ = "agents"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[str] = mapped_column(String(255), nullable=False, default="")
provider_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("llm_providers.id"), nullable=True)
model: Mapped[str] = mapped_column(String(100), nullable=False, default="")
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="You are a helpful AI assistant.")
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#2563eb")
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
user: Mapped["User"] = relationship("User", backref="agents")
provider: Mapped[Optional["LLMProvider"]] = relationship("LLMProvider")
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"name": self.name,
"role": self.role,
"provider_id": self.provider_id,
"model": self.model,
"system_prompt": self.system_prompt,
"color": self.color,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
class ChatRoom(Base):
"""Multi-agent chat room model"""
__tablename__ = "chat_rooms"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
task: Mapped[str] = mapped_column(Text, nullable=False, default="")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="idle") # idle, running, paused, completed, error
execution_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="sequential") # sequential, parallel
max_rounds: Mapped[int] = mapped_column(Integer, default=5)
current_round: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
user: Mapped["User"] = relationship("User", backref="chat_rooms")
agents: Mapped[List["RoomAgent"]] = relationship(
"RoomAgent", back_populates="room", cascade="all, delete-orphan", order_by="RoomAgent.turn_order"
)
messages: Mapped[List["Message"]] = relationship(
"Message", back_populates="room", cascade="all, delete-orphan",
primaryjoin="ChatRoom.id == foreign(Message.room_id)",
order_by="Message.created_at"
)
def to_dict(self, include_messages: bool = False):
result = {
"id": self.id,
"user_id": self.user_id,
"title": self.title,
"task": self.task,
"status": self.status,
"execution_mode": self.execution_mode,
"max_rounds": self.max_rounds,
"current_round": self.current_round,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"agents": [a.to_dict() for a in self.agents]
}
if include_messages:
result["messages"] = [m.to_dict() for m in self.messages]
return result
class RoomAgent(Base):
"""Agent assignment in a chat room (links Agent to Room with room-specific config)"""
__tablename__ = "room_agents"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
room_id: Mapped[str] = mapped_column(String(64), ForeignKey("chat_rooms.id"), nullable=False)
agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("agents.id"), nullable=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[str] = mapped_column(String(255), nullable=False, default="")
provider_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("llm_providers.id"), nullable=True)
model: Mapped[str] = mapped_column(String(100), nullable=False, default="")
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="You are a helpful AI assistant.")
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#2563eb")
turn_order: Mapped[int] = mapped_column(Integer, default=0)
room: Mapped["ChatRoom"] = relationship("ChatRoom", back_populates="agents")
provider: Mapped[Optional["LLMProvider"]] = relationship("LLMProvider")
agent: Mapped[Optional["Agent"]] = relationship("Agent")
def to_dict(self):
return {
"id": self.id,
"room_id": self.room_id,
"agent_id": self.agent_id,
"name": self.name,
"role": self.role,
"provider_id": self.provider_id,
"model": self.model,
"system_prompt": self.system_prompt,
"color": self.color,
"turn_order": self.turn_order
}

View File

@ -1,7 +1,7 @@
"""API routes module""" """API routes module"""
from fastapi import APIRouter from fastapi import APIRouter
from luxx.routes import auth, conversations, messages, tools, providers, chat_rooms, agents, tasks from luxx.routes import auth, conversations, messages, tools, providers, tasks
api_router = APIRouter() api_router = APIRouter()
@ -12,6 +12,4 @@ api_router.include_router(conversations.router)
api_router.include_router(messages.router) api_router.include_router(messages.router)
api_router.include_router(tools.router) api_router.include_router(tools.router)
api_router.include_router(providers.router) api_router.include_router(providers.router)
api_router.include_router(chat_rooms.router)
api_router.include_router(agents.router)
api_router.include_router(tasks.router) api_router.include_router(tasks.router)

View File

@ -1,143 +0,0 @@
"""Standalone Agent CRUD routes"""
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from luxx.database import get_db
from luxx.models import Agent, LLMProvider, User
from luxx.routes.auth import get_current_user
from luxx.utils.helpers import success_response, error_response
router = APIRouter(prefix="/agents", tags=["Agents"])
class AgentCreate(BaseModel):
name: str
role: str = ""
provider_id: Optional[int] = None
model: str = ""
system_prompt: str = "You are a helpful AI assistant."
color: str = "#2563eb"
class AgentUpdate(BaseModel):
name: Optional[str] = None
role: Optional[str] = None
provider_id: Optional[int] = None
model: Optional[str] = None
system_prompt: Optional[str] = None
color: Optional[str] = None
@router.get("/", response_model=dict)
def list_agents(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""List all agents for current user"""
agents = db.query(Agent).filter(
Agent.user_id == current_user.id
).order_by(Agent.updated_at.desc()).all()
return success_response(data=[a.to_dict() for a in agents])
@router.post("/", response_model=dict)
def create_agent(
data: AgentCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new agent"""
model = data.model
provider_id = data.provider_id
if provider_id and not model:
provider = db.query(LLMProvider).filter(
LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id
).first()
if provider:
model = provider.default_model
if not model:
default_provider = db.query(LLMProvider).filter(
LLMProvider.user_id == current_user.id,
LLMProvider.is_default == True
).first()
if default_provider:
provider_id = default_provider.id
model = default_provider.default_model
if not model:
model = "gpt-4"
agent = Agent(
user_id=current_user.id,
name=data.name,
role=data.role,
provider_id=provider_id,
model=model,
system_prompt=data.system_prompt,
color=data.color
)
db.add(agent)
db.commit()
db.refresh(agent)
return success_response(data=agent.to_dict(), message="Agent created")
@router.get("/{agent_id}", response_model=dict)
def get_agent(
agent_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get agent details"""
agent = db.query(Agent).filter(
Agent.id == agent_id,
Agent.user_id == current_user.id
).first()
if not agent:
return error_response("Agent not found", 404)
return success_response(data=agent.to_dict())
@router.put("/{agent_id}", response_model=dict)
def update_agent(
agent_id: int,
data: AgentUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update an agent"""
agent = db.query(Agent).filter(
Agent.id == agent_id,
Agent.user_id == current_user.id
).first()
if not agent:
return error_response("Agent not found", 404)
update_data = data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(agent, key, value)
db.commit()
db.refresh(agent)
return success_response(data=agent.to_dict(), message="Agent updated")
@router.delete("/{agent_id}", response_model=dict)
def delete_agent(
agent_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete an agent"""
agent = db.query(Agent).filter(
Agent.id == agent_id,
Agent.user_id == current_user.id
).first()
if not agent:
return error_response("Agent not found", 404)
db.delete(agent)
db.commit()
return success_response(message="Agent deleted")

View File

@ -1,476 +0,0 @@
"""Chat room routes for multi-agent conversations"""
from typing import Optional, List
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from datetime import datetime
from luxx.database import get_db, SessionLocal
from luxx.models import ChatRoom, RoomAgent, Agent, Message, LLMProvider, User
from luxx.routes.auth import get_current_user
from luxx.services.chat_room import orchestrator
from luxx.utils.helpers import generate_id, success_response, error_response, paginate
router = APIRouter(prefix="/chat-rooms", tags=["Chat Rooms"])
# ============ Request Models ============
class AgentConfig(BaseModel):
agent_id: Optional[int] = None # Link to existing Agent
name: str = ""
role: str = ""
provider_id: Optional[int] = None
model: str = ""
system_prompt: str = "You are a helpful AI assistant."
color: str = "#2563eb"
# Supervision fields
agent_type: str = "producer" # producer | reviewer | executor | observer
reviews_for: Optional[str] = None # JSON: [agent_id_1, agent_id_2]
reviewed_by: Optional[str] = None # JSON: [agent_id_1, agent_id_2]
review_strictness: int = 3
capability_tags: Optional[str] = None # JSON: ["security", "performance"]
class ChatRoomCreate(BaseModel):
title: str
task: str
max_rounds: int = 5
execution_mode: str = "sequential" # sequential | parallel | review_loop
agents: List[AgentConfig] = []
class ChatRoomUpdate(BaseModel):
title: Optional[str] = None
task: Optional[str] = None
max_rounds: Optional[int] = None
status: Optional[str] = None
execution_mode: Optional[str] = None
class AgentCreate(BaseModel):
agent_id: Optional[int] = None # Link to existing Agent
name: str = ""
role: str = ""
provider_id: Optional[int] = None
model: str = ""
system_prompt: str = "You are a helpful AI assistant."
color: str = "#2563eb"
class AgentUpdate(BaseModel):
name: Optional[str] = None
role: Optional[str] = None
provider_id: Optional[int] = None
model: Optional[str] = None
system_prompt: Optional[str] = None
color: Optional[str] = None
turn_order: Optional[int] = None
# ============ Room CRUD ============
@router.get("/", response_model=dict)
def list_rooms(
page: int = 1,
page_size: int = 20,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""List chat rooms"""
query = db.query(ChatRoom).filter(ChatRoom.user_id == current_user.id)
result = paginate(query.order_by(ChatRoom.updated_at.desc()), page, page_size)
return success_response(data={
"items": [r.to_dict() for r in result["items"]],
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"]
})
@router.post("/", response_model=dict)
def create_room(
data: ChatRoomCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a chat room with agents"""
room = ChatRoom(
id=generate_id("room"),
user_id=current_user.id,
title=data.title,
task=data.task,
max_rounds=data.max_rounds,
execution_mode=data.execution_mode
)
db.add(room)
db.flush()
for i, agent_cfg in enumerate(data.agents):
# If agent_id provided, copy config from existing Agent
if agent_cfg.agent_id:
existing = db.query(Agent).filter(
Agent.id == agent_cfg.agent_id,
Agent.user_id == current_user.id
).first()
if existing:
name = agent_cfg.name or existing.name
role = agent_cfg.role or existing.role
provider_id = agent_cfg.provider_id or existing.provider_id
model = agent_cfg.model or existing.model
system_prompt = agent_cfg.system_prompt if agent_cfg.system_prompt != "You are a helpful AI assistant." else existing.system_prompt
color = agent_cfg.color if agent_cfg.color != "#2563eb" else existing.color
agent_id = existing.id
else:
return error_response(f"Agent {agent_cfg.agent_id} not found", 404)
else:
name = agent_cfg.name or f"Agent {i+1}"
role = agent_cfg.role
provider_id = agent_cfg.provider_id
model = agent_cfg.model
system_prompt = agent_cfg.system_prompt
color = agent_cfg.color
agent_id = None
# Resolve model from provider if not specified
if provider_id and not model:
provider = db.query(LLMProvider).filter(
LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id
).first()
if provider:
model = provider.default_model
if not model:
default_provider = db.query(LLMProvider).filter(
LLMProvider.user_id == current_user.id,
LLMProvider.is_default == True
).first()
if default_provider:
provider_id = default_provider.id
model = default_provider.default_model
if not model:
model = "gpt-4"
agent = RoomAgent(
room_id=room.id,
agent_id=agent_id,
name=name,
role=role,
provider_id=provider_id,
model=model,
system_prompt=system_prompt,
color=color,
turn_order=i,
agent_type=agent_cfg.agent_type,
reviews_for=agent_cfg.reviews_for,
reviewed_by=agent_cfg.reviewed_by,
review_strictness=agent_cfg.review_strictness,
capability_tags=agent_cfg.capability_tags
)
db.add(agent)
db.commit()
db.refresh(room)
return success_response(data=room.to_dict(include_messages=False), message="Room created")
@router.get("/{room_id}", response_model=dict)
def get_room(
room_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get room details with agents"""
room = db.query(ChatRoom).filter(
ChatRoom.id == room_id,
ChatRoom.user_id == current_user.id
).first()
if not room:
return error_response("Room not found", 404)
result = room.to_dict(include_messages=False)
# Also get message count
msg_count = db.query(Message).filter(Message.room_id == room_id).count()
result["message_count"] = msg_count
return success_response(data=result)
@router.put("/{room_id}", response_model=dict)
def update_room(
room_id: str,
data: ChatRoomUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update room"""
room = db.query(ChatRoom).filter(
ChatRoom.id == room_id,
ChatRoom.user_id == current_user.id
).first()
if not room:
return error_response("Room not found", 404)
if room.status == "running":
return error_response("Cannot update a running room", 400)
update_data = data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(room, key, value)
db.commit()
db.refresh(room)
return success_response(data=room.to_dict(), message="Room updated")
@router.delete("/{room_id}", response_model=dict)
def delete_room(
room_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete room"""
room = db.query(ChatRoom).filter(
ChatRoom.id == room_id,
ChatRoom.user_id == current_user.id
).first()
if not room:
return error_response("Room not found", 404)
if room.status == "running":
return error_response("Cannot delete a running room. Stop it first.", 400)
db.delete(room)
db.commit()
return success_response(message="Room deleted")
# ============ Room Actions ============
@router.post("/{room_id}/start")
async def start_room(
room_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Start the multi-agent conversation as SSE stream"""
room = db.query(ChatRoom).filter(
ChatRoom.id == room_id,
ChatRoom.user_id == current_user.id
).first()
if not room:
return error_response("Room not found", 404)
if room.status == "running":
return error_response("Room is already running", 400)
async def event_generator():
async for sse_str in orchestrator.run_room(room_id):
yield sse_str
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
@router.post("/{room_id}/stop", response_model=dict)
def stop_room(
room_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Stop a running room"""
room = db.query(ChatRoom).filter(
ChatRoom.id == room_id,
ChatRoom.user_id == current_user.id
).first()
if not room:
return error_response("Room not found", 404)
orchestrator.cancel(room_id)
room.status = "paused"
db.commit()
return success_response(message="Room stopped")
@router.post("/{room_id}/reset", response_model=dict)
def reset_room(
room_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Reset room to initial state, clearing all messages"""
room = db.query(ChatRoom).filter(
ChatRoom.id == room_id,
ChatRoom.user_id == current_user.id
).first()
if not room:
return error_response("Room not found", 404)
if room.status == "running":
return error_response("Cannot reset a running room", 400)
# Delete all messages in this room
db.query(Message).filter(Message.room_id == room_id).delete()
room.status = "idle"
room.current_round = 0
db.commit()
return success_response(message="Room reset")
# ============ Messages ============
@router.get("/{room_id}/messages", response_model=dict)
def get_room_messages(
room_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all messages in a room"""
room = db.query(ChatRoom).filter(
ChatRoom.id == room_id,
ChatRoom.user_id == current_user.id
).first()
if not room:
return error_response("Room not found", 404)
messages = db.query(Message).filter(
Message.room_id == room_id
).order_by(Message.created_at).all()
return success_response(data={
"messages": [m.to_dict() for m in messages],
"room": room.to_dict()
})
# ============ Agent CRUD ============
@router.post("/{room_id}/agents", response_model=dict)
def add_agent(
room_id: str,
data: AgentCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Add an agent to a room"""
room = db.query(ChatRoom).filter(
ChatRoom.id == room_id,
ChatRoom.user_id == current_user.id
).first()
if not room:
return error_response("Room not found", 404)
if room.status == "running":
return error_response("Cannot modify agents while room is running", 400)
# Get max turn_order
max_order = db.query(RoomAgent).filter(
RoomAgent.room_id == room_id
).count()
# If agent_id provided, copy from existing Agent
if data.agent_id:
existing = db.query(Agent).filter(
Agent.id == data.agent_id,
Agent.user_id == current_user.id
).first()
if not existing:
return error_response(f"Agent {data.agent_id} not found", 404)
name = data.name or existing.name
role = data.role or existing.role
provider_id = data.provider_id or existing.provider_id
model = data.model or existing.model
system_prompt = data.system_prompt if data.system_prompt != "You are a helpful AI assistant." else existing.system_prompt
color = data.color if data.color != "#2563eb" else existing.color
agent_id = existing.id
else:
name = data.name or f"Agent {max_order + 1}"
role = data.role
provider_id = data.provider_id
model = data.model
system_prompt = data.system_prompt
color = data.color
agent_id = None
model = model
if provider_id and not model:
provider = db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
if provider:
model = provider.default_model
if not model:
model = "gpt-4"
agent = RoomAgent(
room_id=room_id,
agent_id=agent_id,
name=name,
role=role,
provider_id=provider_id,
model=model,
system_prompt=system_prompt,
color=color,
turn_order=max_order
)
db.add(agent)
db.commit()
db.refresh(agent)
return success_response(data=agent.to_dict(), message="Agent added")
@router.put("/{room_id}/agents/{agent_id}", response_model=dict)
def update_agent(
room_id: str,
agent_id: int,
data: AgentUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update an agent"""
agent = db.query(RoomAgent).filter(
RoomAgent.id == agent_id,
RoomAgent.room_id == room_id
).first()
if not agent:
return error_response("Agent not found", 404)
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
if room and room.status == "running":
return error_response("Cannot modify agents while room is running", 400)
update_data = data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(agent, key, value)
db.commit()
return success_response(data=agent.to_dict(), message="Agent updated")
@router.delete("/{room_id}/agents/{agent_id}", response_model=dict)
def delete_agent(
room_id: str,
agent_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Remove an agent from a room"""
agent = db.query(RoomAgent).filter(
RoomAgent.id == agent_id,
RoomAgent.room_id == room_id
).first()
if not agent:
return error_response("Agent not found", 404)
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
if room and room.status == "running":
return error_response("Cannot remove agents while room is running", 400)
db.delete(agent)
db.commit()
return success_response(message="Agent removed")

View File

@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from luxx.database import get_db from luxx.database import get_db
from luxx.models import User, LLMProvider from luxx.models import User, LLMProvider
from luxx.routes.auth import get_current_user from luxx.routes.auth import get_current_user
from luxx.services.llm_client import LLMClient
from luxx.utils.helpers import success_response from luxx.utils.helpers import success_response
import httpx import httpx
import asyncio import asyncio
@ -178,9 +179,16 @@ def test_provider(
# Test the connection # Test the connection
async def test(): async def test():
async with httpx.AsyncClient(timeout=10.0) as client: client = LLMClient(
response = await client.post( api_key=provider.api_key,
provider.base_url, api_url=provider.base_url,
model=provider.default_model,
provider_type=provider.provider_type
)
endpoint = client.build_endpoint()
async with httpx.AsyncClient(timeout=10.0) as http_client:
response = await http_client.post(
endpoint,
headers={ headers={
"Authorization": f"Bearer {provider.api_key}", "Authorization": f"Bearer {provider.api_key}",
"Content-Type": "application/json" "Content-Type": "application/json"

View File

@ -1,540 +0,0 @@
"""Multi-agent chat room service.
Orchestrates multiple agents taking turns to discuss and solve a task.
Each agent uses its own LLM provider/model and system prompt.
"""
import json
import logging
import asyncio
import traceback
from typing import List, Dict, Any, AsyncGenerator, Optional
from luxx.database import SessionLocal
from luxx.models import ChatRoom, RoomAgent, Message, LLMProvider, Agent, User
from luxx.services.llm_client import LLMClient
from luxx.services.stream_context import StreamState, StepType
from luxx.services.events import sse_event
from luxx.utils.helpers import generate_id
from luxx.tools.core import CommandPermission
logger = logging.getLogger(__name__)
class ChatRoomOrchestrator:
"""Orchestrates multi-agent conversations in a chat room."""
def __init__(self):
self._running_rooms: Dict[str, asyncio.Task] = {}
def is_running(self, room_id: str) -> bool:
return room_id in self._running_rooms and not self._running_rooms[room_id].done()
def cancel(self, room_id: str):
task = self._running_rooms.get(room_id)
if task and not task.done():
task.cancel()
async def run_room(
self,
room_id: str,
db_session=None
) -> AsyncGenerator[str, None]:
"""Run a chat room: agents take turns discussing the task."""
db = db_session or SessionLocal()
own_session = db_session is None
try:
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
if not room:
yield sse_event("error", {"content": "Room not found"})
return
agents = db.query(RoomAgent).filter(
RoomAgent.room_id == room_id
).order_by(RoomAgent.turn_order).all()
if not agents:
yield sse_event("error", {"content": "No agents in room"})
return
room.status = "running"
db.commit()
# Yield room started event
yield sse_event("room_started", {"room_id": room_id, "task": room.task})
# Build conversation history from existing messages
history = self._load_history(room_id, db)
# If no messages yet, add the task as the initial user message
if not history:
task_msg = Message(
id=generate_id("msg"),
room_id=room_id,
role="user",
content=json.dumps({"text": room.task}, ensure_ascii=False),
sender_name="用户",
sender_color="#10b981",
round_number=0
)
db.add(task_msg)
db.commit()
history.append({"role": "user", "content": room.task})
yield sse_event("message", task_msg.to_dict())
# Run rounds based on execution mode
for round_num in range(room.current_round + 1, room.max_rounds + 1):
room.current_round = round_num
db.commit()
yield sse_event("round_start", {
"round": round_num,
"max_rounds": room.max_rounds
})
if room.execution_mode == "parallel":
# Parallel execution: all agents at once
try:
async for event in self._parallel_round(
room_id, agents, history, round_num, db
):
yield event
except asyncio.CancelledError:
room.status = "paused"
db.commit()
yield sse_event("room_paused", {"room_id": room_id, "round": round_num})
return
else:
# Sequential execution: agents take turns
for agent in agents:
try:
async for event in self._agent_turn(
room_id, agent, history, round_num, db
):
yield event
except asyncio.CancelledError:
room.status = "paused"
db.commit()
yield sse_event("room_paused", {"room_id": room_id, "round": round_num})
return
except Exception as e:
logger.error(f"Agent {agent.name} error: {e}\n{traceback.format_exc()}")
yield sse_event("agent_error", {
"agent": agent.name,
"error": str(e)
})
yield sse_event("round_end", {"round": round_num})
# Completed
room.status = "completed"
db.commit()
yield sse_event("room_completed", {
"room_id": room_id,
"total_rounds": room.max_rounds
})
except asyncio.CancelledError:
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
if room:
room.status = "paused"
db.commit()
yield sse_event("room_paused", {"room_id": room_id})
except Exception as e:
logger.error(f"Room error: {e}\n{traceback.format_exc()}")
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
if room:
room.status = "error"
db.commit()
yield sse_event("error", {"content": str(e)})
finally:
if own_session:
db.close()
self._running_rooms.pop(room_id, None)
def _get_creator_permission_level(self, agent: RoomAgent, db) -> int:
"""Get the creator's permission level for this agent.
If the agent is linked to a reusable Agent template, use that template's owner.
Otherwise, use the ChatRoom owner's permission.
"""
# If agent is linked to a reusable Agent template, use that template's owner
if agent.agent_id:
template_agent = db.query(Agent).filter(Agent.id == agent.agent_id).first()
if template_agent:
user = db.query(User).filter(User.id == template_agent.user_id).first()
if user:
return user.permission_level
# Fallback to ChatRoom owner
room = db.query(ChatRoom).filter(ChatRoom.id == agent.room_id).first()
if room:
user = db.query(User).filter(User.id == room.user_id).first()
if user:
return user.permission_level
# Default to READ_ONLY if no user found
return CommandPermission.READ_ONLY
async def _agent_turn(
self,
room_id: str,
agent: RoomAgent,
history: List[Dict],
round_num: int,
db
) -> AsyncGenerator[str, None]:
"""Execute one agent's turn in the conversation with streaming output."""
# Get LLM client for this agent
llm, max_tokens = self._create_llm_client(agent, db)
if not llm:
yield sse_event("agent_error", {
"agent": agent.name,
"error": "No LLM provider configured"
})
return
model = agent.model or llm.default_model or "gpt-4"
# Get creator's permission level for tool execution
creator_permission = self._get_creator_permission_level(agent, db)
# Build messages for this agent
messages = self._build_agent_messages(agent, history)
# Create placeholder message for streaming updates
msg_id = generate_id("msg")
accumulated_content = ""
# Yield streaming start event with placeholder
yield sse_event("message_start", {
"id": msg_id,
"room_id": room_id,
"role": "assistant",
"sender_name": agent.name,
"sender_color": agent.color,
"round_number": round_num
})
# Stream LLM response (without tools for now - chat room agents are text-only)
try:
async for delta in llm.stream_call(
model=model,
messages=messages,
temperature=0.7,
max_tokens=max_tokens or 2000
):
if delta.content:
accumulated_content += delta.content
yield sse_event("message_chunk", {
"id": msg_id,
"content": delta.content,
"accumulated": accumulated_content
})
if delta.is_complete:
break
except Exception as e:
logger.error(f"LLM stream failed for {agent.name}: {e}")
yield sse_event("agent_error", {
"agent": agent.name,
"error": f"LLM stream failed: {str(e)}"
})
await llm.close()
return
# Estimate token count
token_count = len(accumulated_content) // 4
# Build steps for storage
steps = [{"id": "step-0", "index": 0, "type": "text", "content": accumulated_content}]
content_json = {"steps": steps}
# Save complete message to DB
msg = Message(
id=msg_id,
room_id=room_id,
role="assistant",
content=json.dumps(content_json, ensure_ascii=False),
token_count=token_count,
sender_name=agent.name,
sender_color=agent.color,
round_number=round_num
)
db.add(msg)
db.commit()
# Update history
history.append({"role": "assistant", "content": accumulated_content, "sender": agent.name})
# Yield message end event
yield sse_event("message_end", {
"id": msg_id,
"content": accumulated_content,
"token_count": token_count
})
# Also yield the complete message for consistency
msg_dict = msg.to_dict()
yield sse_event("message", msg_dict)
# Close client
await llm.close()
async def _parallel_round(
self,
room_id: str,
agents: List[RoomAgent],
history: List[Dict],
round_num: int,
db
) -> AsyncGenerator[str, None]:
"""Execute all agents in parallel for one round."""
if not agents:
return
# Yield parallel start event
yield sse_event("parallel_start", {
"round": round_num,
"max_rounds": self.max_rounds,
"agents": [{"id": a.id, "name": a.name} for a in agents]
})
# Create all agent tasks
tasks = []
for agent in agents:
task = self._agent_turn_async(
room_id, agent, list(history), round_num, db
)
tasks.append(task)
# Execute in parallel and merge streams
async for event in self._merge_streams(tasks):
yield event
# Yield parallel end event
yield sse_event("parallel_end", {
"round": round_num,
"agent_count": len(agents)
})
async def _agent_turn_async(
self,
room_id: str,
agent: RoomAgent,
history: List[Dict],
round_num: int,
db
) -> AsyncGenerator[Dict[str, Any], None]:
"""Execute a single agent turn asynchronously, yielding event stream."""
# Yield agent status - pending
yield {"type": "agent_status", "agent_id": agent.id, "agent_name": agent.name, "status": "pending"}
# Get LLM client for this agent
llm, max_tokens = self._create_llm_client(agent, db)
if not llm:
yield {"type": "agent_error", "agent_id": agent.id, "agent_name": agent.name, "error": "No LLM provider configured"}
return
model = agent.model or llm.default_model or "gpt-4"
# Get creator's permission level for tool execution (for future use)
creator_permission = self._get_creator_permission_level(agent, db)
# Build messages for this agent
messages = self._build_agent_messages(agent, history)
# Create placeholder message for streaming updates
msg_id = generate_id("msg")
accumulated_content = ""
# Yield agent status - streaming
yield {"type": "agent_status", "agent_id": agent.id, "agent_name": agent.name, "status": "streaming"}
# Yield streaming start event with placeholder
yield {"type": "message_start", "id": msg_id, "room_id": room_id, "role": "assistant",
"sender_name": agent.name, "sender_color": agent.color, "round_number": round_num, "agent_id": agent.id}
# Stream LLM response
try:
async for delta in llm.stream_call(
model=model,
messages=messages,
temperature=0.7,
max_tokens=max_tokens or 2000
):
if delta.content:
accumulated_content += delta.content
# Estimate progress based on content length (assume max ~2000 chars)
progress = min(95, int(len(accumulated_content) / 20))
yield {"type": "message_chunk", "id": msg_id, "content": delta.content,
"accumulated": accumulated_content, "agent_id": agent.id,
"progress": progress}
if delta.is_complete:
break
except Exception as e:
logger.error(f"LLM stream failed for {agent.name}: {e}")
yield {"type": "agent_error", "agent_id": agent.id, "agent_name": agent.name, "error": f"LLM stream failed: {str(e)}"}
await llm.close()
return
# Estimate token count
token_count = len(accumulated_content) // 4
# Build steps for storage
steps = [{"id": "step-0", "index": 0, "type": "text", "content": accumulated_content}]
content_json = {"steps": steps}
# Save complete message to DB
msg = Message(
id=msg_id,
room_id=room_id,
role="assistant",
content=json.dumps(content_json, ensure_ascii=False),
token_count=token_count,
sender_name=agent.name,
sender_color=agent.color,
round_number=round_num
)
db.add(msg)
db.commit()
# Update history
history.append({"role": "assistant", "content": accumulated_content, "sender": agent.name})
# Yield agent status - completed
yield {"type": "agent_status", "agent_id": agent.id, "agent_name": agent.name, "status": "completed"}
# Yield message end event
yield {"type": "message_end", "id": msg_id, "content": accumulated_content,
"token_count": token_count, "agent_id": agent.id}
# Also yield the complete message for consistency
msg_dict = msg.to_dict()
yield {"type": "message", "message": msg_dict}
# Close client
await llm.close()
async def _merge_streams(
self, tasks: List[AsyncGenerator]
) -> AsyncGenerator[str, None]:
"""Merge multiple streams while maintaining real-time output."""
import asyncio
async def consume_stream(stream, queue):
try:
async for event in stream:
await queue.put(event)
except Exception as e:
logger.error(f"Stream error: {e}")
finally:
await queue.put(None) # Mark end
queue = asyncio.Queue()
consumers = [asyncio.create_task(consume_stream(t, queue)) for t in tasks]
completed = 0
while completed < len(tasks):
event = await queue.get()
if event is None:
completed += 1
else:
# Convert dict event to SSE format
if isinstance(event, dict) and "type" in event:
if event["type"] == "message":
yield sse_event("message", event.get("message", {}))
elif event["type"] == "message_start":
yield sse_event("message_start", {k: v for k, v in event.items() if k != "type"})
elif event["type"] == "message_chunk":
yield sse_event("message_chunk", {k: v for k, v in event.items() if k != "type"})
elif event["type"] == "message_end":
yield sse_event("message_end", {k: v for k, v in event.items() if k != "type"})
elif event["type"] == "agent_status":
yield sse_event("agent_status", {k: v for k, v in event.items() if k != "type"})
elif event["type"] == "agent_error":
yield sse_event("agent_error", {k: v for k, v in event.items() if k != "type"})
else:
yield sse_event(event["type"], {k: v for k, v in event.items() if k != "type"})
# Ensure all tasks complete
await asyncio.gather(*consumers, return_exceptions=True)
def _create_llm_client(self, agent: RoomAgent, db) -> tuple:
"""Create LLM client for an agent."""
if agent.provider_id:
provider = db.query(LLMProvider).filter(
LLMProvider.id == agent.provider_id
).first()
if provider:
client = LLMClient(
api_key=provider.api_key,
api_url=provider.base_url,
model=agent.model or provider.default_model,
provider_type=provider.provider_type
)
return client, provider.max_tokens
return None, None
def _build_agent_messages(self, agent: RoomAgent, history: List[Dict]) -> List[Dict]:
"""Build the message list for an agent's LLM call."""
messages = [{"role": "system", "content": agent.system_prompt}]
for h in history:
role = h.get("role", "user")
content = h.get("content", "")
sender = h.get("sender", "")
if role == "user":
messages.append({"role": "user", "content": content})
elif role == "assistant":
# Prefix with sender name so the agent knows who said what
prefix = f"[{sender}]: " if sender else ""
messages.append({"role": "assistant", "content": prefix + content})
return messages
def _load_history(self, room_id: str, db) -> List[Dict]:
"""Load conversation history from existing room messages."""
messages = db.query(Message).filter(
Message.room_id == room_id
).order_by(Message.created_at).all()
history = []
for msg in messages:
# Extract text from message content
text = self._extract_text(msg.content)
entry = {"role": msg.role, "content": text}
if msg.sender_name and msg.role == "assistant":
entry["sender"] = msg.sender_name
history.append(entry)
return history
@staticmethod
def _extract_text(content: str) -> str:
"""Extract text from message content JSON."""
if not content:
return ""
try:
parsed = json.loads(content)
if isinstance(parsed, dict):
# Try steps-based format
steps = parsed.get("steps", [])
if steps:
return "".join(
s.get("content", "") for s in steps
if s.get("type") == "text"
)
# Try simple text format
if "text" in parsed:
return parsed["text"]
return content
except (json.JSONDecodeError, TypeError):
return content
# Singleton orchestrator
orchestrator = ChatRoomOrchestrator()

View File

@ -61,6 +61,10 @@ class AnthropicAdapter(ProviderAdapter):
def provider_type(self) -> str: def provider_type(self) -> str:
return "anthropic" return "anthropic"
@property
def api_path(self) -> str:
return self.MESSAGES_PATH
def build_request( def build_request(
self, self,
model: str, model: str,

View File

@ -28,7 +28,16 @@ class ProviderAdapter(ABC):
str: Provider type, e.g., "openai", "anthropic" str: Provider type, e.g., "openai", "anthropic"
""" """
pass pass
@property
def api_path(self) -> str:
"""API endpoint path suffix to append to base URL
Returns:
str: Path suffix, e.g., "/chat/completions" for OpenAI
"""
return ""
@abstractmethod @abstractmethod
def build_request( def build_request(
self, self,

View File

@ -20,6 +20,10 @@ class OpenAIAdapter(ProviderAdapter):
def provider_type(self) -> str: def provider_type(self) -> str:
return "openai" return "openai"
@property
def api_path(self) -> str:
return "/chat/completions"
def build_request(self, model: str, messages: List[Dict], tools=None, **kwargs) -> tuple: def build_request(self, model: str, messages: List[Dict], tools=None, **kwargs) -> tuple:
api_key = kwargs.get("api_key", "") api_key = kwargs.get("api_key", "")
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"} headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
@ -59,9 +63,15 @@ class OpenAIAdapter(ProviderAdapter):
choices = chunk.get("choices", []) choices = chunk.get("choices", [])
if not choices: if not choices:
# DeepSeek may send usage in a separate chunk without choices
usage = chunk.get("usage") usage = chunk.get("usage")
if usage: if usage:
logger.debug(f"Usage chunk: {usage}") logger.info(f"[TOKEN] Received usage from stream: {usage}")
yield ParsedDelta(usage={
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0)
})
return return
choice = choices[0] choice = choices[0]
@ -80,8 +90,8 @@ class OpenAIAdapter(ProviderAdapter):
for tc in tool_calls: for tc in tool_calls:
yield ParsedDelta(tool_call=tc) yield ParsedDelta(tool_call=tc)
# Set is_complete for final chunks # Set is_complete for final chunks (DeepSeek may return null, "length", "content_filter")
if finish_reason in ("stop", "tool_calls"): if finish_reason and finish_reason not in (None, ""):
yield ParsedDelta(is_complete=True) yield ParsedDelta(is_complete=True)
def parse_response(self, data: Dict) -> Dict: def parse_response(self, data: Dict) -> Dict:

View File

@ -174,6 +174,23 @@ class LLMClient:
"""Whether current Provider supports tool calls""" """Whether current Provider supports tool calls"""
return self.adapter.supports_tools() return self.adapter.supports_tools()
def build_endpoint(self) -> str:
"""Build full API endpoint URL by appending adapter's API path
Handles cases where base_url already contains the path:
- https://api.deepseek.com/v1 + /chat/completions keep as-is
- https://api.deepseek.com + /chat/completions https://api.deepseek.com/chat/completions
"""
base = self.api_url.rstrip('/')
api_path = self.adapter.api_path
if not api_path:
return base
known_endings = ['/chat/completions', '/v1/messages', '/v1/chat/completions']
for ending in known_endings:
if base.endswith(ending):
return base
return base + api_path
async def client(self) -> httpx.AsyncClient: async def client(self) -> httpx.AsyncClient:
"""Get HTTP client (lazy load)""" """Get HTTP client (lazy load)"""
if self._client is None or self._client.is_closed: if self._client is None or self._client.is_closed:
@ -224,7 +241,7 @@ class LLMClient:
model, messages, tools, stream=False, **kwargs model, messages, tools, stream=False, **kwargs
) )
endpoint = self.api_url endpoint = self.build_endpoint()
logger.info(f"Sync call to {endpoint} with model {model}") logger.info(f"Sync call to {endpoint} with model {model}")
try: try:
@ -240,7 +257,8 @@ class LLMClient:
return self.adapter.parse_response(data) return self.adapter.parse_response(data)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}") error_body = e.response.text if e.response else ""
logger.error(f"HTTP error: {e.response.status_code} - {error_body}")
raise raise
except Exception as e: except Exception as e:
logger.error(f"Sync call error: {e}\n{traceback.format_exc()}") logger.error(f"Sync call error: {e}\n{traceback.format_exc()}")
@ -276,7 +294,7 @@ class LLMClient:
model, messages, tools, **kwargs model, messages, tools, **kwargs
) )
endpoint = self.api_url endpoint = self.build_endpoint()
logger.info(f"Stream call to {endpoint} with model {model}") logger.info(f"Stream call to {endpoint} with model {model}")
try: try:
@ -304,12 +322,18 @@ class LLMClient:
yield ParsedDelta(is_complete=True) yield ParsedDelta(is_complete=True)
continue continue
async for delta in self.adapter.parse_stream_chunk(event): async for delta in self.adapter.parse_stream_chunk(event):
if delta.content or delta.has_tool_call() or delta.is_complete: if delta.content or delta.has_tool_call() or delta.is_complete or delta.usage:
yield delta yield delta
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
status_code = e.response.status_code if e.response else "?" status_code = e.response.status_code if e.response else "?"
error_body = e.response.text if e.response else "" error_body = ""
if e.response:
try:
await e.response.aread()
error_body = e.response.text
except Exception:
pass
logger.error(f"HTTP error: {status_code} - {error_body}") logger.error(f"HTTP error: {status_code} - {error_body}")
yield ParsedDelta() yield ParsedDelta()
except Exception as e: except Exception as e: