This commit is contained in:
ViperEkura 2026-04-27 23:48:27 +08:00
parent 89d9a753b6
commit 15ff3e6240
13 changed files with 1140 additions and 88 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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') {

View File

@ -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);

View File

@ -1 +0,0 @@
export { default as pinia } from './store.js'

View File

@ -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()

View File

@ -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 = {}
}
}
})

View File

@ -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,10 +337,27 @@ 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')
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()
}
}
} else {
// Sequential mode (original logic)
abortController = new AbortController()
try {
const url = chatRoomsAPI.start(selectedId.value) const url = chatRoomsAPI.start(selectedId.value)
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
@ -363,12 +389,14 @@ async function startRoom() {
if (e.name !== 'AbortError') error.value = e.message if (e.name !== 'AbortError') error.value = e.message
} finally { } finally {
streaming.value = false streaming.value = false
abortController = null
if (selectedId.value) { if (selectedId.value) {
const res = await chatRoomsAPI.get(selectedId.value) const res = await chatRoomsAPI.get(selectedId.value)
room.value = res.data room.value = res.data
await loadRooms() await loadRooms()
} }
} }
}
} }
function handleSSEEvent(event, data) { function handleSSEEvent(event, data) {
@ -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,6 +726,14 @@ onUnmounted(() => {
</div> </div>
<div v-if="error" class="error-bar">{{ error }}<button @click="error = ''">&times;</button></div> <div v-if="error" class="error-bar">{{ error }}<button @click="error = ''">&times;</button></div>
<div class="chat-messages" ref="messagesContainer" @scroll="handleScroll"> <div class="chat-messages" ref="messagesContainer" @scroll="handleScroll">
<!-- 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-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-if="messages.length === 0 && Object.keys(streamingMessages).length === 0" class="chat-empty"><p>点击开始启动多 Agent 对话</p></div>
<div v-else> <div v-else>
@ -716,7 +769,7 @@ onUnmounted(() => {
</div> </div>
</template> </template>
</div> </div>
<div v-if="streaming" class="streaming-hint"><div class="spinner-sm"></div><span>Agent 正在思考...</span></div> </template>
</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; }

View File

@ -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) {

View File

@ -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,

View File

@ -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)

View File

@ -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,6 +91,20 @@ class ChatRoomOrchestrator:
"max_rounds": room.max_rounds "max_rounds": room.max_rounds
}) })
if room.execution_mode == "parallel":
# Parallel execution: all agents at once
try:
async for event in self._parallel_round(
room_id, agents, history, round_num, db
):
yield event
except asyncio.CancelledError:
room.status = "paused"
db.commit()
yield sse_event("room_paused", {"room_id": room_id, "round": round_num})
return
else:
# Sequential execution: agents take turns
for agent in agents: for agent in agents:
try: try:
async for event in self._agent_turn( async for event in self._agent_turn(
@ -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:

View File

@ -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,