feat: 优化多agent 部分界面
This commit is contained in:
parent
89d9a753b6
commit
6de12aa954
|
|
@ -24,6 +24,10 @@ tools:
|
|||
max_workers: 4
|
||||
max_iterations: 10
|
||||
|
||||
auth:
|
||||
default_permission_level: 3
|
||||
admin_username: admin
|
||||
admin_password: ${ADMIN_PASSWORD:-admin123}
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
|
|
|
|||
|
|
@ -11,6 +11,30 @@
|
|||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.10.0/styles/github.min.css">
|
||||
</head>
|
||||
<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>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -21,4 +21,30 @@
|
|||
<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"/>
|
||||
</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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 6.5 KiB |
|
|
@ -31,9 +31,9 @@
|
|||
<div class="message-footer">
|
||||
<span class="message-time">{{ formatTime(message.created_at) }}</span>
|
||||
<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.completion">{{ formatNumber(message.usage.completion) }} 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.prompt_tokens">{{ formatNumber(message.usage.prompt_tokens) }} in</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_tokens">{{ formatNumber(message.usage.total_tokens) }} total</span>
|
||||
</template>
|
||||
<button v-if="message.role === 'assistant'" class="ghost-btn success" @click="$emit('regenerate', message.id)" title="重新生成">
|
||||
<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 App from './App.vue'
|
||||
import router from './router'
|
||||
import { pinia } from './utils'
|
||||
import pinia from './utils/store.js'
|
||||
|
||||
// 初始化夜间模式
|
||||
if (localStorage.getItem('theme') === 'dark') {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,42 @@
|
|||
--overlay-bg: rgba(0, 0, 0, 0.3);
|
||||
--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-h: var(--text-primary);
|
||||
|
|
@ -123,6 +159,25 @@
|
|||
--overlay-bg: rgba(0, 0, 0, 0.6);
|
||||
--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-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>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
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 ParallelMessages from '../components/ParallelMessages.vue'
|
||||
|
||||
const store = useParallelStreamStore()
|
||||
|
||||
// ============ Sidebar tab state ============
|
||||
const sidebarTab = ref('rooms') // 'agents' | 'rooms' | 'roomAgents'
|
||||
|
|
@ -76,6 +81,10 @@ const statusMap = {
|
|||
const roomAgents = computed(() => room.value?.agents || [])
|
||||
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
|
||||
const groupedMessages = computed(() => {
|
||||
const groups = []
|
||||
|
|
@ -270,7 +279,6 @@ async function selectRoom(id) {
|
|||
}
|
||||
streamingMessages.value = {}
|
||||
selectedId.value = id
|
||||
sidebarTab.value = 'roomAgents'
|
||||
error.value = ''
|
||||
editingRoomAgent.value = null
|
||||
messagesLoading.value = true
|
||||
|
|
@ -328,45 +336,64 @@ async function startRoom() {
|
|||
if (!selectedId.value || streaming.value) return
|
||||
streaming.value = true
|
||||
error.value = ''
|
||||
abortController = new AbortController()
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('access_token')
|
||||
const url = chatRoomsAPI.start(selectedId.value)
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
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) {}
|
||||
}
|
||||
const token = localStorage.getItem('access_token')
|
||||
|
||||
if (isParallelMode.value) {
|
||||
// Parallel mode
|
||||
try {
|
||||
await parallelStreamManager.startParallelRoom(selectedId.value, token)
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') error.value = e.message
|
||||
} finally {
|
||||
streaming.value = false
|
||||
if (selectedId.value) {
|
||||
const res = await chatRoomsAPI.get(selectedId.value)
|
||||
room.value = res.data
|
||||
await loadRooms()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') error.value = e.message
|
||||
} finally {
|
||||
streaming.value = false
|
||||
if (selectedId.value) {
|
||||
const res = await chatRoomsAPI.get(selectedId.value)
|
||||
room.value = res.data
|
||||
await loadRooms()
|
||||
} else {
|
||||
// Sequential mode (original logic)
|
||||
abortController = new AbortController()
|
||||
try {
|
||||
const url = chatRoomsAPI.start(selectedId.value)
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
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) }
|
||||
}
|
||||
|
||||
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 ============
|
||||
|
||||
async function addAgentToRoom(agentFromPool) {
|
||||
|
|
@ -590,7 +626,9 @@ onUnmounted(() => {
|
|||
<!-- 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="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>
|
||||
<button v-if="canEditRoom" class="btn-add-agent-sm" @click="showAddToRoom = true" title="添加 Agent">+</button>
|
||||
</div>
|
||||
|
|
@ -662,8 +700,16 @@ onUnmounted(() => {
|
|||
<div class="toolbar-badges">
|
||||
<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 v-if="isParallelMode" class="mode-badge parallel">⚡ Parallel</span>
|
||||
<span v-else class="mode-badge sequential">📋 Sequential</span>
|
||||
</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">
|
||||
<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>
|
||||
|
|
@ -681,42 +727,50 @@ onUnmounted(() => {
|
|||
</div>
|
||||
<div v-if="error" class="error-bar">{{ error }}<button @click="error = ''">×</button></div>
|
||||
<div class="chat-messages" ref="messagesContainer" @scroll="handleScroll">
|
||||
<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>
|
||||
<!-- Parallel mode streaming view -->
|
||||
<ParallelMessages
|
||||
v-if="isParallelMode && streaming"
|
||||
:room-id="selectedId"
|
||||
mode="parallel"
|
||||
/>
|
||||
<!-- Sequential mode or completed messages -->
|
||||
<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>
|
||||
<MessageBubble v-for="msg in roundMsgs" :key="msg.id" :message="msg" :deletable="false" />
|
||||
</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>
|
||||
<!-- 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 v-if="streaming" class="streaming-hint"><div class="spinner-sm"></div><span>Agent 正在思考...</span></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -839,8 +893,9 @@ onUnmounted(() => {
|
|||
|
||||
.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: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 1rem; padding: 0 0.3rem 0 0; line-height: 1; }
|
||||
.btn-back:hover { color: var(--accent-primary); }
|
||||
.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-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); }
|
||||
|
|
@ -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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ onMounted(async () => {
|
|||
if (convs.status === 'fulfilled' && convs.value.success) {
|
||||
stats.value.conversations = convs.value.data?.total || 0
|
||||
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)
|
||||
}
|
||||
if (tools.status === 'fulfilled' && tools.value.success) {
|
||||
|
|
|
|||
|
|
@ -31,9 +31,13 @@
|
|||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2" class="table-footer">
|
||||
<button @click="openUserModal" class="btn-op">编辑资料</button>
|
||||
<button @click="handleLogout" class="btn-op btn-danger">退出登录</button>
|
||||
<td colspan="2" class="table-footer icon-buttons">
|
||||
<button @click="openUserModal" class="btn-icon" title="编辑资料">
|
||||
<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>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
|
@ -135,7 +139,10 @@
|
|||
<tfoot>
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
|
@ -185,11 +192,15 @@
|
|||
</td>
|
||||
<td class="ops-col">
|
||||
<div class="ops-buttons">
|
||||
<button @click="editProvider(p)" class="btn-op">编辑</button>
|
||||
<button @click="testProvider(p)" :disabled="testing === p.id" class="btn-op">
|
||||
{{ testing === p.id ? '测试中...' : '测试' }}
|
||||
<button @click="editProvider(p)" class="btn-icon" title="编辑">
|
||||
<svg width="16" height="16"><use href="#edit-icon"/></svg>
|
||||
</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 @click="deleteProvider(p)" class="btn-op btn-danger">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -197,7 +208,10 @@
|
|||
<tfoot>
|
||||
<tr>
|
||||
<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>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
|
@ -263,7 +277,10 @@
|
|||
<h2>{{ testResult.success ? '连接成功' : '连接失败' }}</h2>
|
||||
<pre v-if="testResult.json" class="result-json">{{ testResult.json }}</pre>
|
||||
<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>
|
||||
</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 v-if="userFormError" class="error">{{ userFormError }}</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="closeUserModal" class="btn-secondary">取消</button>
|
||||
<button @click="updateUser" :disabled="savingUser" class="btn-primary">{{ savingUser ? '保存中...' : '保存' }}</button>
|
||||
<button @click="closeUserModal" class="btn-secondary">
|
||||
<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>
|
||||
|
|
@ -305,8 +328,14 @@
|
|||
<div v-if="formError" class="error">{{ formError }}</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button @click="closeModal" class="btn-secondary">取消</button>
|
||||
<button @click="saveProvider" :disabled="saving" class="btn-primary">{{ saving ? '保存中...' : '保存' }}</button>
|
||||
<button @click="closeModal" class="btn-secondary">
|
||||
<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>
|
||||
|
|
@ -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); }
|
||||
|
||||
/* 空行提示 */
|
||||
|
||||
/* Provider 单元格 */
|
||||
.provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); }
|
||||
.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: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: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
|
||||
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.models import User
|
||||
from luxx.utils.helpers import hash_password
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
default_user = db.query(User).filter(User.username == "admin").first()
|
||||
if not default_user:
|
||||
default_user = User(
|
||||
username="admin",
|
||||
password_hash=hash_password("admin"),
|
||||
role="admin"
|
||||
admin_username = config.auth_admin_username
|
||||
admin_user = db.query(User).filter(User.username == admin_username).first()
|
||||
if not admin_user:
|
||||
admin_user = User(
|
||||
username=admin_username,
|
||||
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()
|
||||
logger.info("Default admin user created: admin / admin")
|
||||
logger.info(f"Default admin user created: {admin_username} / {config.auth_admin_password}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,22 @@ class Config:
|
|||
def workspace_auto_create(self) -> bool:
|
||||
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
|
||||
config = Config()
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class User(Base):
|
|||
email: Mapped[Optional[str]] = mapped_column(String(120), unique=True, nullable=True)
|
||||
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
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)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
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
|
||||
}
|
||||
|
||||
def is_admin(self) -> bool:
|
||||
"""Check if user has admin privileges"""
|
||||
return self.permission_level >= 4
|
||||
|
||||
|
||||
class Conversation(Base):
|
||||
"""Conversation model"""
|
||||
|
|
@ -284,6 +288,7 @@ class ChatRoom(Base):
|
|||
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)
|
||||
|
|
@ -306,6 +311,7 @@ class ChatRoom(Base):
|
|||
"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,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from pydantic import BaseModel
|
|||
|
||||
from luxx.database import get_db
|
||||
from luxx.models import User, UserSettings
|
||||
from luxx.config import config
|
||||
from luxx.utils.helpers import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
|
|
@ -83,14 +84,14 @@ def get_current_user(
|
|||
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""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")
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/register", response_model=dict)
|
||||
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()
|
||||
if existing_user:
|
||||
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)
|
||||
|
||||
password_hash = hash_password(user_data.password)
|
||||
default_permission = config.auth_default_permission_level
|
||||
|
||||
user = User(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
password_hash=password_hash
|
||||
password_hash=password_hash,
|
||||
permission_level=default_permission
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -65,14 +65,17 @@ def list_conversations(
|
|||
).all()
|
||||
total_tokens = 0
|
||||
for msg in assistant_messages:
|
||||
total_tokens += msg.token_count or 0
|
||||
# Also try to get usage from the usage field
|
||||
# Get usage from the usage field (new format from LLM)
|
||||
if msg.usage:
|
||||
try:
|
||||
usage_obj = json.loads(msg.usage)
|
||||
total_tokens = usage_obj.get("total_tokens", total_tokens)
|
||||
total_tokens += usage_obj.get("total_tokens", 0)
|
||||
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
|
||||
items.append(conv_dict)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ import traceback
|
|||
from typing import List, Dict, Any, AsyncGenerator, Optional
|
||||
|
||||
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.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__)
|
||||
|
||||
|
|
@ -81,7 +82,7 @@ class ChatRoomOrchestrator:
|
|||
history.append({"role": "user", "content": room.task})
|
||||
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):
|
||||
room.current_round = round_num
|
||||
db.commit()
|
||||
|
|
@ -91,10 +92,11 @@ class ChatRoomOrchestrator:
|
|||
"max_rounds": room.max_rounds
|
||||
})
|
||||
|
||||
for agent in agents:
|
||||
if room.execution_mode == "parallel":
|
||||
# Parallel execution: all agents at once
|
||||
try:
|
||||
async for event in self._agent_turn(
|
||||
room_id, agent, history, round_num, db
|
||||
async for event in self._parallel_round(
|
||||
room_id, agents, history, round_num, db
|
||||
):
|
||||
yield event
|
||||
except asyncio.CancelledError:
|
||||
|
|
@ -102,12 +104,25 @@ class ChatRoomOrchestrator:
|
|||
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)
|
||||
})
|
||||
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})
|
||||
|
||||
|
|
@ -137,6 +152,30 @@ class ChatRoomOrchestrator:
|
|||
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,
|
||||
|
|
@ -157,6 +196,9 @@ class ChatRoomOrchestrator:
|
|||
|
||||
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)
|
||||
|
||||
|
|
@ -174,7 +216,7 @@ class ChatRoomOrchestrator:
|
|||
"round_number": round_num
|
||||
})
|
||||
|
||||
# Stream LLM response
|
||||
# Stream LLM response (without tools for now - chat room agents are text-only)
|
||||
try:
|
||||
async for delta in llm.stream_call(
|
||||
model=model,
|
||||
|
|
@ -239,6 +281,182 @@ class ChatRoomOrchestrator:
|
|||
# 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,
|
||||
"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:
|
||||
"""Create LLM client for an agent."""
|
||||
if agent.provider_id:
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ class ToolRegistry:
|
|||
# Automatic permission check (transparent to tool function)
|
||||
if context is not None and context.user_id is not None:
|
||||
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:
|
||||
return {
|
||||
"success": False,
|
||||
|
|
|
|||
Loading…
Reference in New Issue