feat: 优化多agent 部分界面
This commit is contained in:
parent
89d9a753b6
commit
6de12aa954
|
|
@ -24,6 +24,10 @@ tools:
|
||||||
max_workers: 4
|
max_workers: 4
|
||||||
max_iterations: 10
|
max_iterations: 10
|
||||||
|
|
||||||
|
auth:
|
||||||
|
default_permission_level: 3
|
||||||
|
admin_username: admin
|
||||||
|
admin_password: ${ADMIN_PASSWORD:-admin123}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: INFO
|
level: INFO
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,30 @@
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.10.0/styles/github.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.10.0/styles/github.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- SVG Icons Sprite -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
|
||||||
|
<symbol id="arrow-left-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5l-6 5 6 5"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="arrow-right-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 5l6 5-6 5"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="edit-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.5 3.5l5 5M3 17l1.5-5 11-11 4.5 4.5-11 11-4.5.5z"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="trash-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 5l10 10M15 5l-10 10"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="logout-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.5 5H3v10h4.5M13 14l3-5-3-5M16 9H8"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="plus-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M10 4v12M4 10h12"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="check-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 10l5 5 7-8"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,30 @@
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<!-- Admin Icons -->
|
||||||
|
<symbol id="edit-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.5 3.5l5 5M3 17l1.5-5 11-11 4.5 4.5-11 11-4.5.5z"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="trash-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 5l10 10M15 5l-10 10"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="user-icon" viewBox="0 0 20 20">
|
||||||
|
<circle fill="none" stroke="currentColor" stroke-width="1.5" cx="10" cy="7" r="3.5"/>
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M3 17c0-3.5 3-6 7-6s7 2.5 7 6"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="logout-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.5 5H3v10h4.5M13 14l3-5-3-5M16 9H8"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="plus-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M10 4v12M4 10h12"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="check-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 10l5 5 7-8"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="arrow-left-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5l-6 5 6 5"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="arrow-right-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 5l6 5-6 5"/>
|
||||||
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 6.5 KiB |
|
|
@ -31,9 +31,9 @@
|
||||||
<div class="message-footer">
|
<div class="message-footer">
|
||||||
<span class="message-time">{{ formatTime(message.created_at) }}</span>
|
<span class="message-time">{{ formatTime(message.created_at) }}</span>
|
||||||
<template v-if="message.role === 'assistant' && message.usage">
|
<template v-if="message.role === 'assistant' && message.usage">
|
||||||
<span class="token-item" v-if="message.usage.prompt">{{ formatNumber(message.usage.prompt) }} in</span>
|
<span class="token-item" v-if="message.usage.prompt_tokens">{{ formatNumber(message.usage.prompt_tokens) }} in</span>
|
||||||
<span class="token-item" v-if="message.usage.completion">{{ formatNumber(message.usage.completion) }} out</span>
|
<span class="token-item" v-if="message.usage.completion_tokens">{{ formatNumber(message.usage.completion_tokens) }} out</span>
|
||||||
<span class="token-item" v-if="message.usage.total">{{ formatNumber(message.usage.total) }} total</span>
|
<span class="token-item" v-if="message.usage.total_tokens">{{ formatNumber(message.usage.total_tokens) }} total</span>
|
||||||
</template>
|
</template>
|
||||||
<button v-if="message.role === 'assistant'" class="ghost-btn success" @click="$emit('regenerate', message.id)" title="重新生成">
|
<button v-if="message.role === 'assistant'" class="ghost-btn success" @click="$emit('regenerate', message.id)" title="重新生成">
|
||||||
<span v-html="regenerateIcon"></span>
|
<span v-html="regenerateIcon"></span>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,393 @@
|
||||||
|
<template>
|
||||||
|
<div class="parallel-messages">
|
||||||
|
<!-- Execution mode indicator -->
|
||||||
|
<div class="mode-indicator" :class="mode">
|
||||||
|
<span v-if="mode === 'parallel'">⚡ Parallel Execution</span>
|
||||||
|
<span v-else>📋 Sequential Execution</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Round info -->
|
||||||
|
<div v-if="roundInfo.current" class="round-info">
|
||||||
|
Round {{ roundInfo.current }} / {{ roundInfo.max || '?' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Parallel message grid -->
|
||||||
|
<div v-if="mode === 'parallel'" class="parallel-grid">
|
||||||
|
<div
|
||||||
|
v-for="(agent, agentId) in agents"
|
||||||
|
:key="agentId"
|
||||||
|
class="parallel-card"
|
||||||
|
:class="agent.status"
|
||||||
|
>
|
||||||
|
<!-- Agent header -->
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="agent-avatar" :style="{ background: agent.message?.sender_color || '#2563eb' }">
|
||||||
|
{{ agent.name?.charAt(0) || '?' }}
|
||||||
|
</span>
|
||||||
|
<span class="agent-name">{{ agent.name }}</span>
|
||||||
|
<span class="status-badge" :class="agent.status">
|
||||||
|
{{ statusLabels[agent.status] || agent.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div v-if="agent.status === 'streaming'" class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: agent.progress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message content -->
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="agent.status === 'pending'" class="pending-state">
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
<span>Pending...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="agent.status === 'streaming'" class="streaming-content">
|
||||||
|
<div v-if="agent.message?.process_steps?.length" class="process-steps">
|
||||||
|
<div v-for="(step, idx) in agent.message.process_steps" :key="idx" class="step">
|
||||||
|
{{ step.content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="agent.message?.content" class="streaming-text">
|
||||||
|
{{ agent.message.content }}<span class="cursor">▌</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="typing-indicator">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="agent.status === 'completed'" class="completed-content">
|
||||||
|
<MessageBubble v-if="agent.message" :message="agent.message" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="agent.status === 'error'" class="error-state">
|
||||||
|
⚠️ {{ agent.error || 'An error occurred' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sequential message list (compatibility mode) -->
|
||||||
|
<div v-else class="sequential-list">
|
||||||
|
<MessageBubble
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:message="msg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useParallelStreamStore } from '../utils/parallelStreamStore.js'
|
||||||
|
import MessageBubble from './MessageBubble.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
roomId: { type: String, required: true },
|
||||||
|
mode: { type: String, default: 'sequential' },
|
||||||
|
messages: { type: Array, default: () => [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useParallelStreamStore()
|
||||||
|
|
||||||
|
const agents = computed(() => store.rooms[props.roomId]?.agents || {})
|
||||||
|
const roundInfo = computed(() => store.rooms[props.roomId]?.roundInfo || { current: 0, max: 0 })
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
pending: 'Pending',
|
||||||
|
streaming: 'Generating',
|
||||||
|
completed: 'Done',
|
||||||
|
error: 'Error'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.parallel-messages {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-indicator.parallel {
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1));
|
||||||
|
color: var(--mode-parallel-color, #3b82f6);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-indicator.sequential {
|
||||||
|
background: rgba(107, 114, 128, 0.1);
|
||||||
|
color: var(--mode-sequential-color, #6b7280);
|
||||||
|
border: 1px solid rgba(107, 114, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parallel grid layout */
|
||||||
|
.parallel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.parallel-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parallel card */
|
||||||
|
.parallel-card {
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: var(--parallel-card-radius, 12px);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all var(--transition-normal, 300ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parallel-card:hover {
|
||||||
|
box-shadow: var(--parallel-card-shadow-active, 0 0 0 2px rgba(59, 130, 246, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status variants */
|
||||||
|
.parallel-card.streaming {
|
||||||
|
border-color: var(--status-streaming-border, #3b82f6);
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||||
|
animation: pulse-border 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parallel-card.completed {
|
||||||
|
border-color: var(--status-completed-border, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
.parallel-card.error {
|
||||||
|
border-color: var(--status-error-border, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-border {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card header */
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary, #111827);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badge */
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending {
|
||||||
|
background: var(--status-pending-bg, #f3f4f6);
|
||||||
|
color: var(--status-pending-text, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.streaming {
|
||||||
|
background: var(--status-streaming-bg, #eff6ff);
|
||||||
|
color: var(--status-streaming-text, #3b82f6);
|
||||||
|
animation: badge-pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.completed {
|
||||||
|
background: var(--status-completed-bg, #ecfdf5);
|
||||||
|
color: var(--status-completed-text, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.error {
|
||||||
|
background: var(--status-error-bg, #fef2f2);
|
||||||
|
color: var(--status-error-text, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes badge-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.progress-bar {
|
||||||
|
height: var(--progress-height, 4px);
|
||||||
|
background: var(--progress-bg, #e5e7eb);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--progress-fill, linear-gradient(90deg, #3b82f6, #8b5cf6));
|
||||||
|
transition: width var(--transition-normal, 300ms ease);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card body */
|
||||||
|
.card-body {
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pending state */
|
||||||
|
.pending-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid var(--border-color, #e5e7eb);
|
||||||
|
border-top-color: var(--primary-color, #3b82f6);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Streaming content */
|
||||||
|
.streaming-content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-text {
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor {
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
color: var(--primary-color, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typing indicator */
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span {
|
||||||
|
width: var(--typing-dot-size, 6px);
|
||||||
|
height: var(--typing-dot-size, 6px);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary-color, #3b82f6);
|
||||||
|
animation: typing-bounce var(--typing-animation-duration, 1s) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-indicator span:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typing-bounce {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: scale(0.8) translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error state */
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--status-error-bg, #fef2f2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--status-error-text, #ef4444);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Completed content */
|
||||||
|
.completed-content {
|
||||||
|
animation: fade-in 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sequential list */
|
||||||
|
.sequential-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Process steps */
|
||||||
|
.process-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,7 +2,7 @@ import { createApp } from 'vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import { pinia } from './utils'
|
import pinia from './utils/store.js'
|
||||||
|
|
||||||
// 初始化夜间模式
|
// 初始化夜间模式
|
||||||
if (localStorage.getItem('theme') === 'dark') {
|
if (localStorage.getItem('theme') === 'dark') {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,42 @@
|
||||||
--overlay-bg: rgba(0, 0, 0, 0.3);
|
--overlay-bg: rgba(0, 0, 0, 0.3);
|
||||||
--avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa);
|
--avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa);
|
||||||
|
|
||||||
|
/* === Parallel Mode Colors === */
|
||||||
|
--mode-sequential-color: #6b7280;
|
||||||
|
--mode-parallel-color: #3b82f6;
|
||||||
|
|
||||||
|
/* === Agent Status Colors === */
|
||||||
|
--status-pending-bg: #f3f4f6;
|
||||||
|
--status-pending-text: #6b7280;
|
||||||
|
--status-streaming-bg: #eff6ff;
|
||||||
|
--status-streaming-text: #3b82f6;
|
||||||
|
--status-streaming-border: #3b82f6;
|
||||||
|
--status-completed-bg: #ecfdf5;
|
||||||
|
--status-completed-text: #10b981;
|
||||||
|
--status-completed-border: #10b981;
|
||||||
|
--status-error-bg: #fef2f2;
|
||||||
|
--status-error-text: #ef4444;
|
||||||
|
--status-error-border: #ef4444;
|
||||||
|
|
||||||
|
/* === Parallel Card Styles === */
|
||||||
|
--parallel-card-radius: 12px;
|
||||||
|
--parallel-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
--parallel-card-shadow-active: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||||
|
|
||||||
|
/* === Progress Bar === */
|
||||||
|
--progress-height: 4px;
|
||||||
|
--progress-bg: #e5e7eb;
|
||||||
|
--progress-fill: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||||
|
|
||||||
|
/* === Animations === */
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-normal: 300ms ease;
|
||||||
|
--transition-slow: 500ms ease;
|
||||||
|
|
||||||
|
/* === Typing Animation === */
|
||||||
|
--typing-dot-size: 6px;
|
||||||
|
--typing-animation-duration: 1s;
|
||||||
|
|
||||||
/* 兼容旧变量 */
|
/* 兼容旧变量 */
|
||||||
--text: var(--text-primary);
|
--text: var(--text-primary);
|
||||||
--text-h: var(--text-primary);
|
--text-h: var(--text-primary);
|
||||||
|
|
@ -123,6 +159,25 @@
|
||||||
--overlay-bg: rgba(0, 0, 0, 0.6);
|
--overlay-bg: rgba(0, 0, 0, 0.6);
|
||||||
--avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa);
|
--avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa);
|
||||||
|
|
||||||
|
/* === Dark Mode Parallel Colors === */
|
||||||
|
--mode-sequential-color: #9ca3af;
|
||||||
|
--mode-parallel-color: #60a5fa;
|
||||||
|
|
||||||
|
--status-pending-bg: #1f2937;
|
||||||
|
--status-pending-text: #9ca3af;
|
||||||
|
--status-streaming-bg: #1e3a5f;
|
||||||
|
--status-streaming-text: #60a5fa;
|
||||||
|
--status-streaming-border: #3b82f6;
|
||||||
|
--status-completed-bg: #064e3b;
|
||||||
|
--status-completed-text: #34d399;
|
||||||
|
--status-completed-border: #10b981;
|
||||||
|
--status-error-bg: #450a0a;
|
||||||
|
--status-error-text: #f87171;
|
||||||
|
--status-error-border: #ef4444;
|
||||||
|
|
||||||
|
--parallel-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
--progress-bg: #374151;
|
||||||
|
|
||||||
/* 兼容旧变量 */
|
/* 兼容旧变量 */
|
||||||
--text: var(--text-primary);
|
--text: var(--text-primary);
|
||||||
--text-h: var(--text-primary);
|
--text-h: var(--text-primary);
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default as pinia } from './store.js'
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
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.agentId, data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'message_chunk':
|
||||||
|
store.updateAgentContent(roomId, data.agent_id || data.agentId, {
|
||||||
|
content: data.content || '',
|
||||||
|
progress: data.progress || 0
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'message_end':
|
||||||
|
store.completeAgentStream(roomId, data.agent_id || data.agentId, data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'agent_error':
|
||||||
|
store.errorAgentStream(roomId, data.agent_id || data.agentId, data.error)
|
||||||
|
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()
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
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 = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import { chatRoomsAPI, providersAPI, agentsAPI } from '../utils/api.js'
|
import { chatRoomsAPI, providersAPI, agentsAPI } from '../utils/api.js'
|
||||||
|
import { parallelStreamManager } from '../utils/parallelStreamManager.js'
|
||||||
|
import { useParallelStreamStore } from '../utils/parallelStreamStore.js'
|
||||||
import MessageBubble from '../components/MessageBubble.vue'
|
import MessageBubble from '../components/MessageBubble.vue'
|
||||||
|
import ParallelMessages from '../components/ParallelMessages.vue'
|
||||||
|
|
||||||
|
const store = useParallelStreamStore()
|
||||||
|
|
||||||
// ============ Sidebar tab state ============
|
// ============ Sidebar tab state ============
|
||||||
const sidebarTab = ref('rooms') // 'agents' | 'rooms' | 'roomAgents'
|
const sidebarTab = ref('rooms') // 'agents' | 'rooms' | 'roomAgents'
|
||||||
|
|
@ -76,6 +81,10 @@ const statusMap = {
|
||||||
const roomAgents = computed(() => room.value?.agents || [])
|
const roomAgents = computed(() => room.value?.agents || [])
|
||||||
const canEditRoom = computed(() => room.value?.status !== 'running' && !streaming.value)
|
const canEditRoom = computed(() => room.value?.status !== 'running' && !streaming.value)
|
||||||
|
|
||||||
|
// New: execution mode
|
||||||
|
const executionMode = computed(() => room.value?.execution_mode || 'sequential')
|
||||||
|
const isParallelMode = computed(() => executionMode.value === 'parallel')
|
||||||
|
|
||||||
// Group messages by round number for better visual organization
|
// Group messages by round number for better visual organization
|
||||||
const groupedMessages = computed(() => {
|
const groupedMessages = computed(() => {
|
||||||
const groups = []
|
const groups = []
|
||||||
|
|
@ -270,7 +279,6 @@ async function selectRoom(id) {
|
||||||
}
|
}
|
||||||
streamingMessages.value = {}
|
streamingMessages.value = {}
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
sidebarTab.value = 'roomAgents'
|
|
||||||
error.value = ''
|
error.value = ''
|
||||||
editingRoomAgent.value = null
|
editingRoomAgent.value = null
|
||||||
messagesLoading.value = true
|
messagesLoading.value = true
|
||||||
|
|
@ -328,45 +336,64 @@ async function startRoom() {
|
||||||
if (!selectedId.value || streaming.value) return
|
if (!selectedId.value || streaming.value) return
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
abortController = new AbortController()
|
|
||||||
|
|
||||||
try {
|
const token = localStorage.getItem('access_token')
|
||||||
const token = localStorage.getItem('access_token')
|
|
||||||
const url = chatRoomsAPI.start(selectedId.value)
|
if (isParallelMode.value) {
|
||||||
const response = await fetch(url, {
|
// Parallel mode
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
await parallelStreamManager.startParallelRoom(selectedId.value, token)
|
||||||
signal: abortController.signal
|
} catch (e) {
|
||||||
})
|
if (e.name !== 'AbortError') error.value = e.message
|
||||||
if (!response.ok) {
|
} finally {
|
||||||
const err = await response.json().catch(() => ({}))
|
streaming.value = false
|
||||||
throw new Error(err.message || `HTTP ${response.status}`)
|
if (selectedId.value) {
|
||||||
}
|
const res = await chatRoomsAPI.get(selectedId.value)
|
||||||
const reader = response.body.getReader()
|
room.value = res.data
|
||||||
const decoder = new TextDecoder()
|
await loadRooms()
|
||||||
let buffer = ''
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (value) buffer += decoder.decode(value, { stream: true })
|
|
||||||
if (done) break
|
|
||||||
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 { handleSSEEvent(currentEvent, JSON.parse(line.slice(6))) } catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} else {
|
||||||
if (e.name !== 'AbortError') error.value = e.message
|
// Sequential mode (original logic)
|
||||||
} finally {
|
abortController = new AbortController()
|
||||||
streaming.value = false
|
try {
|
||||||
if (selectedId.value) {
|
const url = chatRoomsAPI.start(selectedId.value)
|
||||||
const res = await chatRoomsAPI.get(selectedId.value)
|
const response = await fetch(url, {
|
||||||
room.value = res.data
|
method: 'POST',
|
||||||
await loadRooms()
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
signal: abortController.signal
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(err.message || `HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (value) buffer += decoder.decode(value, { stream: true })
|
||||||
|
if (done) break
|
||||||
|
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 { handleSSEEvent(currentEvent, JSON.parse(line.slice(6))) } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError') error.value = e.message
|
||||||
|
} finally {
|
||||||
|
streaming.value = false
|
||||||
|
abortController = null
|
||||||
|
if (selectedId.value) {
|
||||||
|
const res = await chatRoomsAPI.get(selectedId.value)
|
||||||
|
room.value = res.data
|
||||||
|
await loadRooms()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -449,6 +476,15 @@ async function resetRoom() {
|
||||||
} catch (e) { console.error('Failed to reset room:', e) }
|
} catch (e) { console.error('Failed to reset room:', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateExecutionMode() {
|
||||||
|
if (!room.value || !canEditRoom.value) return
|
||||||
|
try {
|
||||||
|
await chatRoomsAPI.update(room.value.id, { execution_mode: room.value.execution_mode })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update execution mode:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Room Agent Management ============
|
// ============ Room Agent Management ============
|
||||||
|
|
||||||
async function addAgentToRoom(agentFromPool) {
|
async function addAgentToRoom(agentFromPool) {
|
||||||
|
|
@ -590,7 +626,9 @@ onUnmounted(() => {
|
||||||
<!-- Room agents tab (shown when a room is selected) -->
|
<!-- Room agents tab (shown when a room is selected) -->
|
||||||
<div v-if="sidebarTab === 'roomAgents' && room" class="sidebar-tab-content">
|
<div v-if="sidebarTab === 'roomAgents' && room" class="sidebar-tab-content">
|
||||||
<div class="sidebar-header sidebar-header-row">
|
<div class="sidebar-header sidebar-header-row">
|
||||||
<button class="btn-back" @click="sidebarTab = 'rooms'; selectedId = null; room = null">←</button>
|
<button class="btn-back" @click="sidebarTab = 'rooms'; selectedId = null; room = null">
|
||||||
|
<svg width="18" height="18"><use href="#arrow-left-icon"/></svg>
|
||||||
|
</button>
|
||||||
<span class="sidebar-title">{{ room.title }}</span>
|
<span class="sidebar-title">{{ room.title }}</span>
|
||||||
<button v-if="canEditRoom" class="btn-add-agent-sm" @click="showAddToRoom = true" title="添加 Agent">+</button>
|
<button v-if="canEditRoom" class="btn-add-agent-sm" @click="showAddToRoom = true" title="添加 Agent">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -662,8 +700,16 @@ onUnmounted(() => {
|
||||||
<div class="toolbar-badges">
|
<div class="toolbar-badges">
|
||||||
<span class="status-badge" :class="statusMap[room.status]?.class">{{ statusMap[room.status]?.label }}</span>
|
<span class="status-badge" :class="statusMap[room.status]?.class">{{ statusMap[room.status]?.label }}</span>
|
||||||
<span class="round-badge" v-if="room.current_round > 0">R{{ room.current_round }}/{{ room.max_rounds }}</span>
|
<span class="round-badge" v-if="room.current_round > 0">R{{ room.current_round }}/{{ room.max_rounds }}</span>
|
||||||
|
<span v-if="isParallelMode" class="mode-badge parallel">⚡ Parallel</span>
|
||||||
|
<span v-else class="mode-badge sequential">📋 Sequential</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="canEditRoom" class="mode-selector">
|
||||||
|
<select v-model="room.execution_mode" @change="updateExecutionMode" class="mode-select">
|
||||||
|
<option value="sequential">📋 Sequential</option>
|
||||||
|
<option value="parallel">⚡ Parallel</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<button v-if="!streaming && room.status !== 'running'" class="btn-ctrl btn-start" @click="startRoom">
|
<button v-if="!streaming && room.status !== 'running'" class="btn-ctrl btn-start" @click="startRoom">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||||
|
|
@ -681,42 +727,50 @@ onUnmounted(() => {
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="error-bar">{{ error }}<button @click="error = ''">×</button></div>
|
<div v-if="error" class="error-bar">{{ error }}<button @click="error = ''">×</button></div>
|
||||||
<div class="chat-messages" ref="messagesContainer" @scroll="handleScroll">
|
<div class="chat-messages" ref="messagesContainer" @scroll="handleScroll">
|
||||||
<div v-if="messagesLoading" class="loading-messages"><div class="spinner-small"></div><span>加载中...</span></div>
|
<!-- Parallel mode streaming view -->
|
||||||
<div v-else-if="messages.length === 0 && Object.keys(streamingMessages).length === 0" class="chat-empty"><p>点击「开始」启动多 Agent 对话</p></div>
|
<ParallelMessages
|
||||||
<div v-else>
|
v-if="isParallelMode && streaming"
|
||||||
<div v-for="(roundMsgs, rIdx) in groupedMessages" :key="rIdx">
|
:room-id="selectedId"
|
||||||
<div class="round-divider" v-if="rIdx > 0">
|
mode="parallel"
|
||||||
<span class="round-divider-line"></span>
|
/>
|
||||||
<span class="round-divider-label">第 {{ roundMsgs[0].round_number }} 轮</span>
|
<!-- Sequential mode or completed messages -->
|
||||||
<span class="round-divider-line"></span>
|
<template v-else>
|
||||||
</div>
|
<div v-if="messagesLoading" class="loading-messages"><div class="spinner-small"></div><span>加载中...</span></div>
|
||||||
<div class="round-group">
|
<div v-else-if="messages.length === 0 && Object.keys(streamingMessages).length === 0" class="chat-empty"><p>点击「开始」启动多 Agent 对话</p></div>
|
||||||
<div class="round-header" v-if="roundMsgs[0].round_number">
|
<div v-else>
|
||||||
<span class="round-header-icon">💬</span>
|
<div v-for="(roundMsgs, rIdx) in groupedMessages" :key="rIdx">
|
||||||
<span class="round-header-text">第 {{ roundMsgs[0].round_number }} 轮对话</span>
|
<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>
|
||||||
<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 Object.values(streamingMessages)"
|
||||||
|
:key="msg.id + '-' + idx"
|
||||||
|
:message="msg"
|
||||||
|
:deletable="false"
|
||||||
|
class="streaming-message"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<!-- Streaming messages -->
|
</template>
|
||||||
<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 Object.values(streamingMessages)"
|
|
||||||
:key="msg.id + '-' + idx"
|
|
||||||
:message="msg"
|
|
||||||
:deletable="false"
|
|
||||||
class="streaming-message"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div v-if="streaming" class="streaming-hint"><div class="spinner-sm"></div><span>Agent 正在思考...</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -839,8 +893,9 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.sidebar-header { padding: 0.75rem; border-bottom: 1px solid var(--border-light); }
|
.sidebar-header { padding: 0.75rem; border-bottom: 1px solid var(--border-light); }
|
||||||
.sidebar-header-row { display: flex; justify-content: space-between; align-items: center; }
|
.sidebar-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.btn-back { background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 1rem; padding: 0 0.3rem 0 0; line-height: 1; }
|
.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 { color: var(--accent-primary); }
|
.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; }
|
.sidebar-title { font-size: 0.8rem; font-weight: 700; color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.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 { 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); }
|
.btn-new-conv:hover { background: var(--accent-primary-hover); }
|
||||||
|
|
@ -917,6 +972,13 @@ textarea.fi { resize: vertical; min-height: 50px; }
|
||||||
.status-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 8px; font-weight: 600; }
|
.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); }
|
.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-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); }
|
||||||
|
|
||||||
.toolbar-actions { display: flex; gap: 0.4rem; align-items: center; }
|
.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-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 { background: #22c55e; color: white; }
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ onMounted(async () => {
|
||||||
if (convs.status === 'fulfilled' && convs.value.success) {
|
if (convs.status === 'fulfilled' && convs.value.success) {
|
||||||
stats.value.conversations = convs.value.data?.total || 0
|
stats.value.conversations = convs.value.data?.total || 0
|
||||||
const items = convs.value.data?.items || []
|
const items = convs.value.data?.items || []
|
||||||
|
console.log('[HomeView] conversations response:', convs.value.data)
|
||||||
|
console.log('[HomeView] items with token_count:', items.map(i => ({ id: i.id, token_count: i.token_count })))
|
||||||
stats.value.totalTokens = items.reduce((sum, c) => sum + (c.token_count || 0), 0)
|
stats.value.totalTokens = items.reduce((sum, c) => sum + (c.token_count || 0), 0)
|
||||||
}
|
}
|
||||||
if (tools.status === 'fulfilled' && tools.value.success) {
|
if (tools.status === 'fulfilled' && tools.value.success) {
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,13 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" class="table-footer">
|
<td colspan="2" class="table-footer icon-buttons">
|
||||||
<button @click="openUserModal" class="btn-op">编辑资料</button>
|
<button @click="openUserModal" class="btn-icon" title="编辑资料">
|
||||||
<button @click="handleLogout" class="btn-op btn-danger">退出登录</button>
|
<svg width="18" height="18"><use href="#edit-icon"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="handleLogout" class="btn-icon btn-danger" title="退出登录">
|
||||||
|
<svg width="18" height="18"><use href="#logout-icon"/></svg>
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
@ -135,7 +139,10 @@
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" class="table-footer">
|
<td colspan="2" class="table-footer">
|
||||||
<button @click="saveModelSettings" class="btn-primary">保存设置</button>
|
<button @click="saveModelSettings" class="btn-primary">
|
||||||
|
<svg width="16" height="16" style="margin-right: 6px;"><use href="#check-icon"/></svg>
|
||||||
|
保存设置
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
@ -185,11 +192,15 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="ops-col">
|
<td class="ops-col">
|
||||||
<div class="ops-buttons">
|
<div class="ops-buttons">
|
||||||
<button @click="editProvider(p)" class="btn-op">编辑</button>
|
<button @click="editProvider(p)" class="btn-icon" title="编辑">
|
||||||
<button @click="testProvider(p)" :disabled="testing === p.id" class="btn-op">
|
<svg width="16" height="16"><use href="#edit-icon"/></svg>
|
||||||
{{ testing === p.id ? '测试中...' : '测试' }}
|
</button>
|
||||||
|
<button @click="testProvider(p)" :disabled="testing === p.id" class="btn-icon" title="测试连接">
|
||||||
|
<svg width="16" height="16" :class="{ spinning: testing === p.id }"><use href="#check-icon"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="deleteProvider(p)" class="btn-icon btn-danger" title="删除">
|
||||||
|
<svg width="16" height="16"><use href="#trash-icon"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button @click="deleteProvider(p)" class="btn-op btn-danger">删除</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -197,7 +208,10 @@
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="table-footer">
|
<td colspan="4" class="table-footer">
|
||||||
<button @click="showModal = true" class="btn-primary">+ 添加 Provider</button>
|
<button @click="showModal = true" class="btn-primary">
|
||||||
|
<svg width="16" height="16" style="margin-right: 6px;"><use href="#plus-icon"/></svg>
|
||||||
|
添加 Provider
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|
@ -263,7 +277,10 @@
|
||||||
<h2>{{ testResult.success ? '连接成功' : '连接失败' }}</h2>
|
<h2>{{ testResult.success ? '连接成功' : '连接失败' }}</h2>
|
||||||
<pre v-if="testResult.json" class="result-json">{{ testResult.json }}</pre>
|
<pre v-if="testResult.json" class="result-json">{{ testResult.json }}</pre>
|
||||||
<div v-else class="result-message">{{ testResult.message }}</div>
|
<div v-else class="result-message">{{ testResult.message }}</div>
|
||||||
<button @click="testResult = null" class="btn-primary">确定</button>
|
<button @click="testResult = null" class="btn-primary">
|
||||||
|
<svg width="16" height="16" style="margin-right: 6px;"><use href="#check-icon"/></svg>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -277,8 +294,14 @@
|
||||||
<div class="form-group"><label>新密码 <span class="optional">(留空不修改)</span></label><input v-model="userFormEdit.password" type="password" placeholder="输入新密码" /></div>
|
<div class="form-group"><label>新密码 <span class="optional">(留空不修改)</span></label><input v-model="userFormEdit.password" type="password" placeholder="输入新密码" /></div>
|
||||||
<div v-if="userFormError" class="error">{{ userFormError }}</div>
|
<div v-if="userFormError" class="error">{{ userFormError }}</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button @click="closeUserModal" class="btn-secondary">取消</button>
|
<button @click="closeUserModal" class="btn-secondary">
|
||||||
<button @click="updateUser" :disabled="savingUser" class="btn-primary">{{ savingUser ? '保存中...' : '保存' }}</button>
|
<svg width="16" height="16" style="margin-right: 6px;"><use href="#x-icon"/></svg>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button @click="updateUser" :disabled="savingUser" class="btn-primary">
|
||||||
|
<svg width="16" height="16" style="margin-right: 6px;"><use href="#check-icon"/></svg>
|
||||||
|
{{ savingUser ? '保存中...' : '保存' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -305,8 +328,14 @@
|
||||||
<div v-if="formError" class="error">{{ formError }}</div>
|
<div v-if="formError" class="error">{{ formError }}</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button @click="closeModal" class="btn-secondary">取消</button>
|
<button @click="closeModal" class="btn-secondary">
|
||||||
<button @click="saveProvider" :disabled="saving" class="btn-primary">{{ saving ? '保存中...' : '保存' }}</button>
|
<svg width="16" height="16" style="margin-right: 6px;"><use href="#x-icon"/></svg>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button @click="saveProvider" :disabled="saving" class="btn-primary">
|
||||||
|
<svg width="16" height="16" style="margin-right: 6px;"><use href="#check-icon"/></svg>
|
||||||
|
{{ saving ? '保存中...' : '保存' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -664,8 +693,6 @@ onMounted(() => {
|
||||||
/* 表格底部 */
|
/* 表格底部 */
|
||||||
.table-footer { text-align: right; padding: 0.75rem 1rem; background: var(--bg-secondary); border-top: 1px solid var(--border-light); }
|
.table-footer { text-align: right; padding: 0.75rem 1rem; background: var(--bg-secondary); border-top: 1px solid var(--border-light); }
|
||||||
|
|
||||||
/* 空行提示 */
|
|
||||||
|
|
||||||
/* Provider 单元格 */
|
/* Provider 单元格 */
|
||||||
.provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); }
|
.provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); }
|
||||||
.provider-badges { display: flex; gap: 0.35rem; margin-top: 0.35rem; }
|
.provider-badges { display: flex; gap: 0.35rem; margin-top: 0.35rem; }
|
||||||
|
|
@ -684,6 +711,17 @@ onMounted(() => {
|
||||||
.btn-op.btn-danger { color: var(--danger-color); border-color: var(--danger-bg); }
|
.btn-op.btn-danger { color: var(--danger-color); border-color: var(--danger-bg); }
|
||||||
.btn-op.btn-danger:hover { background: var(--danger-bg); }
|
.btn-op.btn-danger:hover { background: var(--danger-bg); }
|
||||||
|
|
||||||
|
/* 用户操作按钮组 */
|
||||||
|
.user-actions { padding: 0.75rem 1rem !important; }
|
||||||
|
.action-group { display: flex; gap: 0.5rem; justify-content: flex-end; align-items: center; }
|
||||||
|
.btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; background: var(--bg-secondary); border: 1px solid var(--border-light); border-radius: 8px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
|
||||||
|
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-primary); }
|
||||||
|
.btn-icon.btn-danger { color: var(--danger-color); }
|
||||||
|
.btn-icon.btn-danger:hover { background: var(--danger-bg); border-color: var(--danger-color); color: white; }
|
||||||
|
.btn-icon svg { width: 18px; height: 18px; }
|
||||||
|
.spinning { animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* 主要按钮 */
|
/* 主要按钮 */
|
||||||
.btn-primary { padding: 0.5rem 1rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; font-size: 0.85rem; cursor: pointer; transition: all 0.2s; }
|
.btn-primary { padding: 0.5rem 1rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; font-size: 0.85rem; cursor: pointer; transition: all 0.2s; }
|
||||||
.btn-primary:hover { background: var(--accent-primary-hover); }
|
.btn-primary:hover { background: var(--accent-primary-hover); }
|
||||||
|
|
|
||||||
|
|
@ -18,23 +18,25 @@ async def lifespan(app: FastAPI):
|
||||||
from luxx.models import User, Conversation, Message, Project, LLMProvider, ChatRoom, RoomAgent # noqa
|
from luxx.models import User, Conversation, Message, Project, LLMProvider, ChatRoom, RoomAgent # noqa
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
# Create default test user if not exists
|
# Create default admin user if not exists, using config values
|
||||||
from luxx.database import SessionLocal
|
from luxx.database import SessionLocal
|
||||||
from luxx.models import User
|
from luxx.models import User
|
||||||
from luxx.utils.helpers import hash_password
|
from luxx.utils.helpers import hash_password
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
default_user = db.query(User).filter(User.username == "admin").first()
|
admin_username = config.auth_admin_username
|
||||||
if not default_user:
|
admin_user = db.query(User).filter(User.username == admin_username).first()
|
||||||
default_user = User(
|
if not admin_user:
|
||||||
username="admin",
|
admin_user = User(
|
||||||
password_hash=hash_password("admin"),
|
username=admin_username,
|
||||||
role="admin"
|
password_hash=hash_password(config.auth_admin_password),
|
||||||
|
role="admin",
|
||||||
|
permission_level=4 # ADMIN level
|
||||||
)
|
)
|
||||||
db.add(default_user)
|
db.add(admin_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info("Default admin user created: admin / admin")
|
logger.info(f"Default admin user created: {admin_username} / {config.auth_admin_password}")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,22 @@ class Config:
|
||||||
def workspace_auto_create(self) -> bool:
|
def workspace_auto_create(self) -> bool:
|
||||||
return self.get("workspace.auto_create", True)
|
return self.get("workspace.auto_create", True)
|
||||||
|
|
||||||
|
# Auth configuration
|
||||||
|
@property
|
||||||
|
def auth_default_permission_level(self) -> int:
|
||||||
|
"""Default permission level for new users (1=READ_ONLY, 2=WRITE, 3=EXECUTE, 4=ADMIN)"""
|
||||||
|
return self.get("auth.default_permission_level", 3)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_admin_username(self) -> str:
|
||||||
|
"""Default admin username for initial setup"""
|
||||||
|
return self.get("auth.admin_username", "admin")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_admin_password(self) -> str:
|
||||||
|
"""Default admin password for initial setup"""
|
||||||
|
return self.get("auth.admin_password", "admin123")
|
||||||
|
|
||||||
|
|
||||||
# Global configuration instance
|
# Global configuration instance
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ class User(Base):
|
||||||
email: Mapped[Optional[str]] = mapped_column(String(120), unique=True, nullable=True)
|
email: Mapped[Optional[str]] = mapped_column(String(120), unique=True, nullable=True)
|
||||||
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
role: Mapped[str] = mapped_column(String(20), default="user")
|
role: Mapped[str] = mapped_column(String(20), default="user")
|
||||||
permission_level: Mapped[int] = mapped_column(Integer, default=1)
|
permission_level: Mapped[int] = mapped_column(Integer, default=1) # 1=READ_ONLY, 2=WRITE, 3=EXECUTE, 4=ADMIN
|
||||||
workspace_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
workspace_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
|
||||||
|
|
@ -124,6 +124,10 @@ class User(Base):
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
"""Check if user has admin privileges"""
|
||||||
|
return self.permission_level >= 4
|
||||||
|
|
||||||
|
|
||||||
class Conversation(Base):
|
class Conversation(Base):
|
||||||
"""Conversation model"""
|
"""Conversation model"""
|
||||||
|
|
@ -284,6 +288,7 @@ class ChatRoom(Base):
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
task: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
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
|
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)
|
max_rounds: Mapped[int] = mapped_column(Integer, default=5)
|
||||||
current_round: Mapped[int] = mapped_column(Integer, default=0)
|
current_round: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
|
||||||
|
|
@ -306,6 +311,7 @@ class ChatRoom(Base):
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"task": self.task,
|
"task": self.task,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
|
"execution_mode": self.execution_mode,
|
||||||
"max_rounds": self.max_rounds,
|
"max_rounds": self.max_rounds,
|
||||||
"current_round": self.current_round,
|
"current_round": self.current_round,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
from luxx.database import get_db
|
from luxx.database import get_db
|
||||||
from luxx.models import User, UserSettings
|
from luxx.models import User, UserSettings
|
||||||
|
from luxx.config import config
|
||||||
from luxx.utils.helpers import (
|
from luxx.utils.helpers import (
|
||||||
hash_password,
|
hash_password,
|
||||||
verify_password,
|
verify_password,
|
||||||
|
|
@ -83,14 +84,14 @@ def get_current_user(
|
||||||
|
|
||||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||||
"""Require admin role"""
|
"""Require admin role"""
|
||||||
if current_user.permission_level < 4:
|
if not current_user.is_admin():
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=dict)
|
@router.post("/register", response_model=dict)
|
||||||
def register(user_data: UserRegister, db: Session = Depends(get_db)):
|
def register(user_data: UserRegister, db: Session = Depends(get_db)):
|
||||||
"""User registration"""
|
"""User registration with configurable default permission level"""
|
||||||
existing_user = db.query(User).filter(User.username == user_data.username).first()
|
existing_user = db.query(User).filter(User.username == user_data.username).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
return error_response("Username already exists", 400)
|
return error_response("Username already exists", 400)
|
||||||
|
|
@ -101,17 +102,20 @@ def register(user_data: UserRegister, db: Session = Depends(get_db)):
|
||||||
return error_response("Email already registered", 400)
|
return error_response("Email already registered", 400)
|
||||||
|
|
||||||
password_hash = hash_password(user_data.password)
|
password_hash = hash_password(user_data.password)
|
||||||
|
default_permission = config.auth_default_permission_level
|
||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
username=user_data.username,
|
username=user_data.username,
|
||||||
email=user_data.email,
|
email=user_data.email,
|
||||||
password_hash=password_hash
|
password_hash=password_hash,
|
||||||
|
permission_level=default_permission
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
return success_response(
|
return success_response(
|
||||||
data={"id": user.id, "username": user.username},
|
data={"id": user.id, "username": user.username, "permission_level": user.permission_level},
|
||||||
message="Registration successful"
|
message="Registration successful"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,14 +65,17 @@ def list_conversations(
|
||||||
).all()
|
).all()
|
||||||
total_tokens = 0
|
total_tokens = 0
|
||||||
for msg in assistant_messages:
|
for msg in assistant_messages:
|
||||||
total_tokens += msg.token_count or 0
|
# Get usage from the usage field (new format from LLM)
|
||||||
# Also try to get usage from the usage field
|
|
||||||
if msg.usage:
|
if msg.usage:
|
||||||
try:
|
try:
|
||||||
usage_obj = json.loads(msg.usage)
|
usage_obj = json.loads(msg.usage)
|
||||||
total_tokens = usage_obj.get("total_tokens", total_tokens)
|
total_tokens += usage_obj.get("total_tokens", 0)
|
||||||
except:
|
except:
|
||||||
pass
|
# Fallback to token_count field
|
||||||
|
total_tokens += msg.token_count or 0
|
||||||
|
else:
|
||||||
|
# Legacy messages without usage field
|
||||||
|
total_tokens += msg.token_count or 0
|
||||||
conv_dict['token_count'] = total_tokens
|
conv_dict['token_count'] = total_tokens
|
||||||
items.append(conv_dict)
|
items.append(conv_dict)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,12 @@ import traceback
|
||||||
from typing import List, Dict, Any, AsyncGenerator, Optional
|
from typing import List, Dict, Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
from luxx.database import SessionLocal
|
from luxx.database import SessionLocal
|
||||||
from luxx.models import ChatRoom, RoomAgent, Message, LLMProvider
|
from luxx.models import ChatRoom, RoomAgent, Message, LLMProvider, Agent, User
|
||||||
from luxx.services.llm_client import LLMClient
|
from luxx.services.llm_client import LLMClient
|
||||||
from luxx.services.stream_context import StreamState, StepType
|
from luxx.services.stream_context import StreamState, StepType
|
||||||
from luxx.services.events import sse_event
|
from luxx.services.events import sse_event
|
||||||
from luxx.utils.helpers import generate_id
|
from luxx.utils.helpers import generate_id
|
||||||
|
from luxx.tools.core import CommandPermission
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -81,7 +82,7 @@ class ChatRoomOrchestrator:
|
||||||
history.append({"role": "user", "content": room.task})
|
history.append({"role": "user", "content": room.task})
|
||||||
yield sse_event("message", task_msg.to_dict())
|
yield sse_event("message", task_msg.to_dict())
|
||||||
|
|
||||||
# Run rounds
|
# Run rounds based on execution mode
|
||||||
for round_num in range(room.current_round + 1, room.max_rounds + 1):
|
for round_num in range(room.current_round + 1, room.max_rounds + 1):
|
||||||
room.current_round = round_num
|
room.current_round = round_num
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -91,10 +92,11 @@ class ChatRoomOrchestrator:
|
||||||
"max_rounds": room.max_rounds
|
"max_rounds": room.max_rounds
|
||||||
})
|
})
|
||||||
|
|
||||||
for agent in agents:
|
if room.execution_mode == "parallel":
|
||||||
|
# Parallel execution: all agents at once
|
||||||
try:
|
try:
|
||||||
async for event in self._agent_turn(
|
async for event in self._parallel_round(
|
||||||
room_id, agent, history, round_num, db
|
room_id, agents, history, round_num, db
|
||||||
):
|
):
|
||||||
yield event
|
yield event
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|
@ -102,12 +104,25 @@ class ChatRoomOrchestrator:
|
||||||
db.commit()
|
db.commit()
|
||||||
yield sse_event("room_paused", {"room_id": room_id, "round": round_num})
|
yield sse_event("room_paused", {"room_id": room_id, "round": round_num})
|
||||||
return
|
return
|
||||||
except Exception as e:
|
else:
|
||||||
logger.error(f"Agent {agent.name} error: {e}\n{traceback.format_exc()}")
|
# Sequential execution: agents take turns
|
||||||
yield sse_event("agent_error", {
|
for agent in agents:
|
||||||
"agent": agent.name,
|
try:
|
||||||
"error": str(e)
|
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})
|
yield sse_event("round_end", {"round": round_num})
|
||||||
|
|
||||||
|
|
@ -137,6 +152,30 @@ class ChatRoomOrchestrator:
|
||||||
db.close()
|
db.close()
|
||||||
self._running_rooms.pop(room_id, None)
|
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(
|
async def _agent_turn(
|
||||||
self,
|
self,
|
||||||
room_id: str,
|
room_id: str,
|
||||||
|
|
@ -157,6 +196,9 @@ class ChatRoomOrchestrator:
|
||||||
|
|
||||||
model = agent.model or llm.default_model or "gpt-4"
|
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
|
# Build messages for this agent
|
||||||
messages = self._build_agent_messages(agent, history)
|
messages = self._build_agent_messages(agent, history)
|
||||||
|
|
||||||
|
|
@ -174,7 +216,7 @@ class ChatRoomOrchestrator:
|
||||||
"round_number": round_num
|
"round_number": round_num
|
||||||
})
|
})
|
||||||
|
|
||||||
# Stream LLM response
|
# Stream LLM response (without tools for now - chat room agents are text-only)
|
||||||
try:
|
try:
|
||||||
async for delta in llm.stream_call(
|
async for delta in llm.stream_call(
|
||||||
model=model,
|
model=model,
|
||||||
|
|
@ -239,6 +281,182 @@ class ChatRoomOrchestrator:
|
||||||
# Close client
|
# Close client
|
||||||
await llm.close()
|
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,
|
||||||
|
"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
|
||||||
|
yield {"type": "message_chunk", "id": msg_id, "content": delta.content,
|
||||||
|
"accumulated": accumulated_content, "agent_id": agent.id}
|
||||||
|
|
||||||
|
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:
|
def _create_llm_client(self, agent: RoomAgent, db) -> tuple:
|
||||||
"""Create LLM client for an agent."""
|
"""Create LLM client for an agent."""
|
||||||
if agent.provider_id:
|
if agent.provider_id:
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,9 @@ class ToolRegistry:
|
||||||
# Automatic permission check (transparent to tool function)
|
# Automatic permission check (transparent to tool function)
|
||||||
if context is not None and context.user_id is not None:
|
if context is not None and context.user_id is not None:
|
||||||
user_level = context.extra.get("user_permission_level", CommandPermission.READ_ONLY)
|
user_level = context.extra.get("user_permission_level", CommandPermission.READ_ONLY)
|
||||||
|
# Ensure user_level is an enum for comparison and display
|
||||||
|
if isinstance(user_level, int):
|
||||||
|
user_level = CommandPermission(user_level)
|
||||||
if user_level < tool.required_permission:
|
if user_level < tool.required_permission:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue