debug
This commit is contained in:
parent
89d9a753b6
commit
15ff3e6240
|
|
@ -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 = []
|
||||||
|
|
@ -328,45 +337,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 +477,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) {
|
||||||
|
|
@ -662,8 +699,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 +726,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>
|
||||||
|
|
@ -917,6 +970,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) {
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,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 +307,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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,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 +91,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 +103,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})
|
||||||
|
|
||||||
|
|
@ -239,6 +253,179 @@ 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"
|
||||||
|
|
||||||
|
# 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