This commit is contained in:
ViperEkura 2026-04-28 13:16:06 +08:00
parent 89d9a753b6
commit 1e3f9a229a
20 changed files with 1326 additions and 121 deletions

View File

@ -24,6 +24,10 @@ tools:
max_workers: 4
max_iterations: 10
auth:
default_permission_level: 3
admin_username: admin
admin_password: ${ADMIN_PASSWORD:-admin123}
logging:
level: INFO

View File

@ -11,6 +11,30 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.10.0/styles/github.min.css">
</head>
<body>
<!-- SVG Icons Sprite -->
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="arrow-left-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5l-6 5 6 5"/>
</symbol>
<symbol id="arrow-right-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 5l6 5-6 5"/>
</symbol>
<symbol id="edit-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.5 3.5l5 5M3 17l1.5-5 11-11 4.5 4.5-11 11-4.5.5z"/>
</symbol>
<symbol id="trash-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 5l10 10M15 5l-10 10"/>
</symbol>
<symbol id="logout-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.5 5H3v10h4.5M13 14l3-5-3-5M16 9H8"/>
</symbol>
<symbol id="plus-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M10 4v12M4 10h12"/>
</symbol>
<symbol id="check-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 10l5 5 7-8"/>
</symbol>
</svg>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>

View File

@ -21,4 +21,30 @@
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
<!-- Admin Icons -->
<symbol id="edit-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.5 3.5l5 5M3 17l1.5-5 11-11 4.5 4.5-11 11-4.5.5z"/>
</symbol>
<symbol id="trash-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 5l10 10M15 5l-10 10"/>
</symbol>
<symbol id="user-icon" viewBox="0 0 20 20">
<circle fill="none" stroke="currentColor" stroke-width="1.5" cx="10" cy="7" r="3.5"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M3 17c0-3.5 3-6 7-6s7 2.5 7 6"/>
</symbol>
<symbol id="logout-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.5 5H3v10h4.5M13 14l3-5-3-5M16 9H8"/>
</symbol>
<symbol id="plus-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M10 4v12M4 10h12"/>
</symbol>
<symbol id="check-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 10l5 5 7-8"/>
</symbol>
<symbol id="arrow-left-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 5l-6 5 6 5"/>
</symbol>
<symbol id="arrow-right-icon" viewBox="0 0 20 20">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 5l6 5-6 5"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -31,9 +31,9 @@
<div class="message-footer">
<span class="message-time">{{ formatTime(message.created_at) }}</span>
<template v-if="message.role === 'assistant' && message.usage">
<span class="token-item" v-if="message.usage.prompt">{{ formatNumber(message.usage.prompt) }} in</span>
<span class="token-item" v-if="message.usage.completion">{{ formatNumber(message.usage.completion) }} out</span>
<span class="token-item" v-if="message.usage.total">{{ formatNumber(message.usage.total) }} total</span>
<span class="token-item" v-if="message.usage.prompt_tokens">{{ formatNumber(message.usage.prompt_tokens) }} in</span>
<span class="token-item" v-if="message.usage.completion_tokens">{{ formatNumber(message.usage.completion_tokens) }} out</span>
<span class="token-item" v-if="message.usage.total_tokens">{{ formatNumber(message.usage.total_tokens) }} total</span>
</template>
<button v-if="message.role === 'assistant'" class="ghost-btn success" @click="$emit('regenerate', message.id)" title="重新生成">
<span v-html="regenerateIcon"></span>

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 App from './App.vue'
import router from './router'
import { pinia } from './utils'
import pinia from './utils/store.js'
// 初始化夜间模式
if (localStorage.getItem('theme') === 'dark') {

View File

@ -53,6 +53,42 @@
--overlay-bg: rgba(0, 0, 0, 0.3);
--avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa);
/* === Parallel Mode Colors === */
--mode-sequential-color: #6b7280;
--mode-parallel-color: #3b82f6;
/* === Agent Status Colors === */
--status-pending-bg: #f3f4f6;
--status-pending-text: #6b7280;
--status-streaming-bg: #eff6ff;
--status-streaming-text: #3b82f6;
--status-streaming-border: #3b82f6;
--status-completed-bg: #ecfdf5;
--status-completed-text: #10b981;
--status-completed-border: #10b981;
--status-error-bg: #fef2f2;
--status-error-text: #ef4444;
--status-error-border: #ef4444;
/* === Parallel Card Styles === */
--parallel-card-radius: 12px;
--parallel-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--parallel-card-shadow-active: 0 0 0 2px rgba(59, 130, 246, 0.3);
/* === Progress Bar === */
--progress-height: 4px;
--progress-bg: #e5e7eb;
--progress-fill: linear-gradient(90deg, #3b82f6, #8b5cf6);
/* === Animations === */
--transition-fast: 150ms ease;
--transition-normal: 300ms ease;
--transition-slow: 500ms ease;
/* === Typing Animation === */
--typing-dot-size: 6px;
--typing-animation-duration: 1s;
/* 兼容旧变量 */
--text: var(--text-primary);
--text-h: var(--text-primary);
@ -123,6 +159,25 @@
--overlay-bg: rgba(0, 0, 0, 0.6);
--avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa);
/* === Dark Mode Parallel Colors === */
--mode-sequential-color: #9ca3af;
--mode-parallel-color: #60a5fa;
--status-pending-bg: #1f2937;
--status-pending-text: #9ca3af;
--status-streaming-bg: #1e3a5f;
--status-streaming-text: #60a5fa;
--status-streaming-border: #3b82f6;
--status-completed-bg: #064e3b;
--status-completed-text: #34d399;
--status-completed-border: #10b981;
--status-error-bg: #450a0a;
--status-error-text: #f87171;
--status-error-border: #ef4444;
--parallel-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--progress-bg: #374151;
/* 兼容旧变量 */
--text: var(--text-primary);
--text-h: var(--text-primary);

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>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { chatRoomsAPI, providersAPI, agentsAPI } from '../utils/api.js'
import { parallelStreamManager } from '../utils/parallelStreamManager.js'
import { useParallelStreamStore } from '../utils/parallelStreamStore.js'
import MessageBubble from '../components/MessageBubble.vue'
import ParallelMessages from '../components/ParallelMessages.vue'
const store = useParallelStreamStore()
// ============ Sidebar tab state ============
const sidebarTab = ref('rooms') // 'agents' | 'rooms' | 'roomAgents'
@ -76,6 +81,10 @@ const statusMap = {
const roomAgents = computed(() => room.value?.agents || [])
const canEditRoom = computed(() => room.value?.status !== 'running' && !streaming.value)
// New: execution mode
const executionMode = computed(() => room.value?.execution_mode || 'sequential')
const isParallelMode = computed(() => executionMode.value === 'parallel')
// Group messages by round number for better visual organization
const groupedMessages = computed(() => {
const groups = []
@ -328,45 +337,64 @@ async function startRoom() {
if (!selectedId.value || streaming.value) return
streaming.value = true
error.value = ''
abortController = new AbortController()
try {
const token = localStorage.getItem('access_token')
const url = chatRoomsAPI.start(selectedId.value)
const response = await fetch(url, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
signal: abortController.signal
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.message || `HTTP ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (value) buffer += decoder.decode(value, { stream: true })
if (done) break
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) currentEvent = line.slice(7).trim()
else if (line.startsWith('data: ')) {
try { handleSSEEvent(currentEvent, JSON.parse(line.slice(6))) } catch (e) {}
}
const token = localStorage.getItem('access_token')
if (isParallelMode.value) {
// Parallel mode
try {
await parallelStreamManager.startParallelRoom(selectedId.value, token)
} catch (e) {
if (e.name !== 'AbortError') error.value = e.message
} finally {
streaming.value = false
if (selectedId.value) {
const res = await chatRoomsAPI.get(selectedId.value)
room.value = res.data
await loadRooms()
}
}
} catch (e) {
if (e.name !== 'AbortError') error.value = e.message
} finally {
streaming.value = false
if (selectedId.value) {
const res = await chatRoomsAPI.get(selectedId.value)
room.value = res.data
await loadRooms()
} else {
// Sequential mode (original logic)
abortController = new AbortController()
try {
const url = chatRoomsAPI.start(selectedId.value)
const response = await fetch(url, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
signal: abortController.signal
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.message || `HTTP ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (value) buffer += decoder.decode(value, { stream: true })
if (done) break
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) currentEvent = line.slice(7).trim()
else if (line.startsWith('data: ')) {
try { handleSSEEvent(currentEvent, JSON.parse(line.slice(6))) } catch (e) {}
}
}
}
} catch (e) {
if (e.name !== 'AbortError') error.value = e.message
} finally {
streaming.value = false
abortController = null
if (selectedId.value) {
const res = await chatRoomsAPI.get(selectedId.value)
room.value = res.data
await loadRooms()
}
}
}
}
@ -449,6 +477,15 @@ async function resetRoom() {
} catch (e) { console.error('Failed to reset room:', e) }
}
async function updateExecutionMode() {
if (!room.value || !canEditRoom.value) return
try {
await chatRoomsAPI.update(room.value.id, { execution_mode: room.value.execution_mode })
} catch (e) {
console.error('Failed to update execution mode:', e)
}
}
// ============ Room Agent Management ============
async function addAgentToRoom(agentFromPool) {
@ -590,7 +627,9 @@ onUnmounted(() => {
<!-- Room agents tab (shown when a room is selected) -->
<div v-if="sidebarTab === 'roomAgents' && room" class="sidebar-tab-content">
<div class="sidebar-header sidebar-header-row">
<button class="btn-back" @click="sidebarTab = 'rooms'; selectedId = null; room = null"></button>
<button class="btn-back" @click="sidebarTab = 'rooms'; selectedId = null; room = null">
<svg width="18" height="18"><use href="#arrow-left-icon"/></svg>
</button>
<span class="sidebar-title">{{ room.title }}</span>
<button v-if="canEditRoom" class="btn-add-agent-sm" @click="showAddToRoom = true" title="添加 Agent">+</button>
</div>
@ -662,8 +701,16 @@ onUnmounted(() => {
<div class="toolbar-badges">
<span class="status-badge" :class="statusMap[room.status]?.class">{{ statusMap[room.status]?.label }}</span>
<span class="round-badge" v-if="room.current_round > 0">R{{ room.current_round }}/{{ room.max_rounds }}</span>
<span v-if="isParallelMode" class="mode-badge parallel"> Parallel</span>
<span v-else class="mode-badge sequential">📋 Sequential</span>
</div>
</div>
<div v-if="canEditRoom" class="mode-selector">
<select v-model="room.execution_mode" @change="updateExecutionMode" class="mode-select">
<option value="sequential">📋 Sequential</option>
<option value="parallel"> Parallel</option>
</select>
</div>
<div class="toolbar-actions">
<button v-if="!streaming && room.status !== 'running'" class="btn-ctrl btn-start" @click="startRoom">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
@ -681,42 +728,50 @@ onUnmounted(() => {
</div>
<div v-if="error" class="error-bar">{{ error }}<button @click="error = ''">&times;</button></div>
<div class="chat-messages" ref="messagesContainer" @scroll="handleScroll">
<div v-if="messagesLoading" class="loading-messages"><div class="spinner-small"></div><span>加载中...</span></div>
<div v-else-if="messages.length === 0 && Object.keys(streamingMessages).length === 0" class="chat-empty"><p>点击开始启动多 Agent 对话</p></div>
<div v-else>
<div v-for="(roundMsgs, rIdx) in groupedMessages" :key="rIdx">
<div class="round-divider" v-if="rIdx > 0">
<span class="round-divider-line"></span>
<span class="round-divider-label"> {{ roundMsgs[0].round_number }} </span>
<span class="round-divider-line"></span>
</div>
<div class="round-group">
<div class="round-header" v-if="roundMsgs[0].round_number">
<span class="round-header-icon">💬</span>
<span class="round-header-text"> {{ roundMsgs[0].round_number }} 轮对话</span>
<!-- Parallel mode streaming view -->
<ParallelMessages
v-if="isParallelMode && streaming"
:room-id="selectedId"
mode="parallel"
/>
<!-- Sequential mode or completed messages -->
<template v-else>
<div v-if="messagesLoading" class="loading-messages"><div class="spinner-small"></div><span>加载中...</span></div>
<div v-else-if="messages.length === 0 && Object.keys(streamingMessages).length === 0" class="chat-empty"><p>点击开始启动多 Agent 对话</p></div>
<div v-else>
<div v-for="(roundMsgs, rIdx) in groupedMessages" :key="rIdx">
<div class="round-divider" v-if="rIdx > 0">
<span class="round-divider-line"></span>
<span class="round-divider-label"> {{ roundMsgs[0].round_number }} </span>
<span class="round-divider-line"></span>
</div>
<div class="round-group">
<div class="round-header" v-if="roundMsgs[0].round_number">
<span class="round-header-icon">💬</span>
<span class="round-header-text"> {{ roundMsgs[0].round_number }} 轮对话</span>
</div>
<MessageBubble v-for="msg in roundMsgs" :key="msg.id" :message="msg" :deletable="false" />
</div>
<MessageBubble v-for="msg in roundMsgs" :key="msg.id" :message="msg" :deletable="false" />
</div>
<!-- Streaming messages -->
<template v-if="Object.keys(streamingMessages).length > 0">
<div class="round-divider" v-if="messages.length > 0">
<span class="round-divider-line"></span>
<span class="round-divider-label">进行中...</span>
<span class="round-divider-line"></span>
</div>
<div class="round-group">
<MessageBubble
v-for="(msg, idx) in Object.values(streamingMessages)"
:key="msg.id + '-' + idx"
:message="msg"
:deletable="false"
class="streaming-message"
/>
</div>
</template>
</div>
<!-- Streaming messages -->
<template v-if="Object.keys(streamingMessages).length > 0">
<div class="round-divider" v-if="messages.length > 0">
<span class="round-divider-line"></span>
<span class="round-divider-label">进行中...</span>
<span class="round-divider-line"></span>
</div>
<div class="round-group">
<MessageBubble
v-for="(msg, idx) in Object.values(streamingMessages)"
:key="msg.id + '-' + idx"
:message="msg"
:deletable="false"
class="streaming-message"
/>
</div>
</template>
</div>
<div v-if="streaming" class="streaming-hint"><div class="spinner-sm"></div><span>Agent 正在思考...</span></div>
</template>
</div>
</div>
</main>
@ -839,8 +894,9 @@ onUnmounted(() => {
.sidebar-header { padding: 0.75rem; border-bottom: 1px solid var(--border-light); }
.sidebar-header-row { display: flex; justify-content: space-between; align-items: center; }
.btn-back { background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 1rem; padding: 0 0.3rem 0 0; line-height: 1; }
.btn-back:hover { color: var(--accent-primary); }
.btn-back { background: var(--accent-primary); border: 1px solid var(--accent-primary); color: white; cursor: pointer; padding: 0.35rem; margin-right: 0.5rem; line-height: 1; display: flex; align-items: center; justify-content: center; border-radius: 6px; transition: all 0.2s; flex-shrink: 0; }
.btn-back:hover { background: var(--accent-primary-hover); border-color: var(--accent-primary-hover); }
.btn-back svg { width: 18px; height: 18px; }
.sidebar-title { font-size: 0.8rem; font-weight: 700; color: var(--text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn-new-conv { width: 100%; padding: 0.5rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; transition: all 0.2s; }
.btn-new-conv:hover { background: var(--accent-primary-hover); }
@ -917,6 +973,13 @@ textarea.fi { resize: vertical; min-height: 50px; }
.status-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 8px; font-weight: 600; }
.round-badge { font-size: 0.65rem; color: var(--text-secondary); }
.mode-badge { font-size: 0.6rem; padding: 0.1rem 0.4rem; border-radius: 8px; font-weight: 600; }
.mode-badge.parallel { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
.mode-badge.sequential { background: rgba(107, 114, 128, 0.1); color: #6b7280; }
.mode-selector { margin-left: auto; }
.mode-select { padding: 0.25rem 0.5rem; font-size: 0.75rem; border: 1px solid var(--border-light); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); }
.toolbar-actions { display: flex; gap: 0.4rem; align-items: center; }
.btn-ctrl { display: flex; align-items: center; gap: 0.3rem; padding: 0.35rem 0.75rem; border: none; border-radius: 6px; font-size: 0.8rem; cursor: pointer; font-weight: 500; }
.btn-start { background: #22c55e; color: white; }

View File

@ -40,6 +40,8 @@ onMounted(async () => {
if (convs.status === 'fulfilled' && convs.value.success) {
stats.value.conversations = convs.value.data?.total || 0
const items = convs.value.data?.items || []
console.log('[HomeView] conversations response:', convs.value.data)
console.log('[HomeView] items with token_count:', items.map(i => ({ id: i.id, token_count: i.token_count })))
stats.value.totalTokens = items.reduce((sum, c) => sum + (c.token_count || 0), 0)
}
if (tools.status === 'fulfilled' && tools.value.success) {

View File

@ -31,9 +31,13 @@
</tbody>
<tfoot>
<tr>
<td colspan="2" class="table-footer">
<button @click="openUserModal" class="btn-op">编辑资料</button>
<button @click="handleLogout" class="btn-op btn-danger">退出登录</button>
<td colspan="2" class="table-footer icon-buttons">
<button @click="openUserModal" class="btn-icon" title="编辑资料">
<svg width="18" height="18"><use href="#edit-icon"/></svg>
</button>
<button @click="handleLogout" class="btn-icon btn-danger" title="退出登录">
<svg width="18" height="18"><use href="#logout-icon"/></svg>
</button>
</td>
</tr>
</tfoot>
@ -135,7 +139,10 @@
<tfoot>
<tr>
<td colspan="2" class="table-footer">
<button @click="saveModelSettings" class="btn-primary">保存设置</button>
<button @click="saveModelSettings" class="btn-primary">
<svg width="16" height="16" style="margin-right: 6px;"><use href="#check-icon"/></svg>
保存设置
</button>
</td>
</tr>
</tfoot>
@ -185,11 +192,15 @@
</td>
<td class="ops-col">
<div class="ops-buttons">
<button @click="editProvider(p)" class="btn-op">编辑</button>
<button @click="testProvider(p)" :disabled="testing === p.id" class="btn-op">
{{ testing === p.id ? '测试中...' : '测试' }}
<button @click="editProvider(p)" class="btn-icon" title="编辑">
<svg width="16" height="16"><use href="#edit-icon"/></svg>
</button>
<button @click="testProvider(p)" :disabled="testing === p.id" class="btn-icon" title="测试连接">
<svg width="16" height="16" :class="{ spinning: testing === p.id }"><use href="#check-icon"/></svg>
</button>
<button @click="deleteProvider(p)" class="btn-icon btn-danger" title="删除">
<svg width="16" height="16"><use href="#trash-icon"/></svg>
</button>
<button @click="deleteProvider(p)" class="btn-op btn-danger">删除</button>
</div>
</td>
</tr>
@ -197,7 +208,10 @@
<tfoot>
<tr>
<td colspan="4" class="table-footer">
<button @click="showModal = true" class="btn-primary">+ 添加 Provider</button>
<button @click="showModal = true" class="btn-primary">
<svg width="16" height="16" style="margin-right: 6px;"><use href="#plus-icon"/></svg>
添加 Provider
</button>
</td>
</tr>
</tfoot>
@ -263,7 +277,10 @@
<h2>{{ testResult.success ? '连接成功' : '连接失败' }}</h2>
<pre v-if="testResult.json" class="result-json">{{ testResult.json }}</pre>
<div v-else class="result-message">{{ testResult.message }}</div>
<button @click="testResult = null" class="btn-primary">确定</button>
<button @click="testResult = null" class="btn-primary">
<svg width="16" height="16" style="margin-right: 6px;"><use href="#check-icon"/></svg>
确定
</button>
</template>
</div>
</div>
@ -277,8 +294,14 @@
<div class="form-group"><label>新密码 <span class="optional">(留空不修改)</span></label><input v-model="userFormEdit.password" type="password" placeholder="输入新密码" /></div>
<div v-if="userFormError" class="error">{{ userFormError }}</div>
<div class="modal-actions">
<button @click="closeUserModal" class="btn-secondary">取消</button>
<button @click="updateUser" :disabled="savingUser" class="btn-primary">{{ savingUser ? '保存中...' : '保存' }}</button>
<button @click="closeUserModal" class="btn-secondary">
<svg width="16" height="16" style="margin-right: 6px;"><use href="#x-icon"/></svg>
取消
</button>
<button @click="updateUser" :disabled="savingUser" class="btn-primary">
<svg width="16" height="16" style="margin-right: 6px;"><use href="#check-icon"/></svg>
{{ savingUser ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
@ -305,8 +328,14 @@
<div v-if="formError" class="error">{{ formError }}</div>
<div class="modal-actions">
<button @click="closeModal" class="btn-secondary">取消</button>
<button @click="saveProvider" :disabled="saving" class="btn-primary">{{ saving ? '保存中...' : '保存' }}</button>
<button @click="closeModal" class="btn-secondary">
<svg width="16" height="16" style="margin-right: 6px;"><use href="#x-icon"/></svg>
取消
</button>
<button @click="saveProvider" :disabled="saving" class="btn-primary">
<svg width="16" height="16" style="margin-right: 6px;"><use href="#check-icon"/></svg>
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
@ -684,6 +713,16 @@ onMounted(() => {
.btn-op.btn-danger { color: var(--danger-color); border-color: var(--danger-bg); }
.btn-op.btn-danger:hover { background: var(--danger-bg); }
/* 图标按钮 */
.icon-buttons { display: flex; gap: 0.5rem; justify-content: flex-end; align-items: center; }
.btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; background: var(--bg-secondary); border: 1px solid var(--border-light); border-radius: 8px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-primary); }
.btn-icon.btn-danger { color: var(--danger-color); }
.btn-icon.btn-danger:hover { background: var(--danger-bg); border-color: var(--danger-color); color: white; }
.btn-icon svg { width: 18px; height: 18px; }
.spinning { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* 主要按钮 */
.btn-primary { padding: 0.5rem 1rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; font-size: 0.85rem; cursor: pointer; transition: all 0.2s; }
.btn-primary:hover { background: var(--accent-primary-hover); }

View File

@ -18,23 +18,25 @@ async def lifespan(app: FastAPI):
from luxx.models import User, Conversation, Message, Project, LLMProvider, ChatRoom, RoomAgent # noqa
init_db()
# Create default test user if not exists
# Create default admin user if not exists, using config values
from luxx.database import SessionLocal
from luxx.models import User
from luxx.utils.helpers import hash_password
db = SessionLocal()
try:
default_user = db.query(User).filter(User.username == "admin").first()
if not default_user:
default_user = User(
username="admin",
password_hash=hash_password("admin"),
role="admin"
admin_username = config.auth_admin_username
admin_user = db.query(User).filter(User.username == admin_username).first()
if not admin_user:
admin_user = User(
username=admin_username,
password_hash=hash_password(config.auth_admin_password),
role="admin",
permission_level=4 # ADMIN level
)
db.add(default_user)
db.add(admin_user)
db.commit()
logger.info("Default admin user created: admin / admin")
logger.info(f"Default admin user created: {admin_username} / {config.auth_admin_password}")
finally:
db.close()

View File

@ -134,6 +134,22 @@ class Config:
def workspace_auto_create(self) -> bool:
return self.get("workspace.auto_create", True)
# Auth configuration
@property
def auth_default_permission_level(self) -> int:
"""Default permission level for new users (1=READ_ONLY, 2=WRITE, 3=EXECUTE, 4=ADMIN)"""
return self.get("auth.default_permission_level", 3)
@property
def auth_admin_username(self) -> str:
"""Default admin username for initial setup"""
return self.get("auth.admin_username", "admin")
@property
def auth_admin_password(self) -> str:
"""Default admin password for initial setup"""
return self.get("auth.admin_password", "admin123")
# Global configuration instance
config = Config()

View File

@ -103,7 +103,7 @@ class User(Base):
email: Mapped[Optional[str]] = mapped_column(String(120), unique=True, nullable=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(String(20), default="user")
permission_level: Mapped[int] = mapped_column(Integer, default=1)
permission_level: Mapped[int] = mapped_column(Integer, default=1) # 1=READ_ONLY, 2=WRITE, 3=EXECUTE, 4=ADMIN
workspace_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
@ -124,6 +124,10 @@ class User(Base):
"created_at": self.created_at.isoformat() if self.created_at else None
}
def is_admin(self) -> bool:
"""Check if user has admin privileges"""
return self.permission_level >= 4
class Conversation(Base):
"""Conversation model"""
@ -284,6 +288,7 @@ class ChatRoom(Base):
title: Mapped[str] = mapped_column(String(255), nullable=False)
task: Mapped[str] = mapped_column(Text, nullable=False, default="")
status: Mapped[str] = mapped_column(String(20), nullable=False, default="idle") # idle, running, paused, completed, error
execution_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="sequential") # sequential, parallel
max_rounds: Mapped[int] = mapped_column(Integer, default=5)
current_round: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
@ -306,6 +311,7 @@ class ChatRoom(Base):
"title": self.title,
"task": self.task,
"status": self.status,
"execution_mode": self.execution_mode,
"max_rounds": self.max_rounds,
"current_round": self.current_round,
"created_at": self.created_at.isoformat() if self.created_at else None,

View File

@ -7,6 +7,7 @@ from pydantic import BaseModel
from luxx.database import get_db
from luxx.models import User, UserSettings
from luxx.config import config
from luxx.utils.helpers import (
hash_password,
verify_password,
@ -83,14 +84,14 @@ def get_current_user(
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Require admin role"""
if current_user.permission_level < 4:
if not current_user.is_admin():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
return current_user
@router.post("/register", response_model=dict)
def register(user_data: UserRegister, db: Session = Depends(get_db)):
"""User registration"""
"""User registration with configurable default permission level"""
existing_user = db.query(User).filter(User.username == user_data.username).first()
if existing_user:
return error_response("Username already exists", 400)
@ -101,17 +102,20 @@ def register(user_data: UserRegister, db: Session = Depends(get_db)):
return error_response("Email already registered", 400)
password_hash = hash_password(user_data.password)
default_permission = config.auth_default_permission_level
user = User(
username=user_data.username,
email=user_data.email,
password_hash=password_hash
password_hash=password_hash,
permission_level=default_permission
)
db.add(user)
db.commit()
db.refresh(user)
return success_response(
data={"id": user.id, "username": user.username},
data={"id": user.id, "username": user.username, "permission_level": user.permission_level},
message="Registration successful"
)

View File

@ -65,14 +65,17 @@ def list_conversations(
).all()
total_tokens = 0
for msg in assistant_messages:
total_tokens += msg.token_count or 0
# Also try to get usage from the usage field
# Get usage from the usage field (new format from LLM)
if msg.usage:
try:
usage_obj = json.loads(msg.usage)
total_tokens = usage_obj.get("total_tokens", total_tokens)
total_tokens += usage_obj.get("total_tokens", 0)
except:
pass
# Fallback to token_count field
total_tokens += msg.token_count or 0
else:
# Legacy messages without usage field
total_tokens += msg.token_count or 0
conv_dict['token_count'] = total_tokens
items.append(conv_dict)

View File

@ -10,11 +10,12 @@ import traceback
from typing import List, Dict, Any, AsyncGenerator, Optional
from luxx.database import SessionLocal
from luxx.models import ChatRoom, RoomAgent, Message, LLMProvider
from luxx.models import ChatRoom, RoomAgent, Message, LLMProvider, Agent, User
from luxx.services.llm_client import LLMClient
from luxx.services.stream_context import StreamState, StepType
from luxx.services.events import sse_event
from luxx.utils.helpers import generate_id
from luxx.tools.core import CommandPermission
logger = logging.getLogger(__name__)
@ -81,7 +82,7 @@ class ChatRoomOrchestrator:
history.append({"role": "user", "content": room.task})
yield sse_event("message", task_msg.to_dict())
# Run rounds
# Run rounds based on execution mode
for round_num in range(room.current_round + 1, room.max_rounds + 1):
room.current_round = round_num
db.commit()
@ -91,10 +92,11 @@ class ChatRoomOrchestrator:
"max_rounds": room.max_rounds
})
for agent in agents:
if room.execution_mode == "parallel":
# Parallel execution: all agents at once
try:
async for event in self._agent_turn(
room_id, agent, history, round_num, db
async for event in self._parallel_round(
room_id, agents, history, round_num, db
):
yield event
except asyncio.CancelledError:
@ -102,12 +104,25 @@ class ChatRoomOrchestrator:
db.commit()
yield sse_event("room_paused", {"room_id": room_id, "round": round_num})
return
except Exception as e:
logger.error(f"Agent {agent.name} error: {e}\n{traceback.format_exc()}")
yield sse_event("agent_error", {
"agent": agent.name,
"error": str(e)
})
else:
# Sequential execution: agents take turns
for agent in agents:
try:
async for event in self._agent_turn(
room_id, agent, history, round_num, db
):
yield event
except asyncio.CancelledError:
room.status = "paused"
db.commit()
yield sse_event("room_paused", {"room_id": room_id, "round": round_num})
return
except Exception as e:
logger.error(f"Agent {agent.name} error: {e}\n{traceback.format_exc()}")
yield sse_event("agent_error", {
"agent": agent.name,
"error": str(e)
})
yield sse_event("round_end", {"round": round_num})
@ -137,6 +152,30 @@ class ChatRoomOrchestrator:
db.close()
self._running_rooms.pop(room_id, None)
def _get_creator_permission_level(self, agent: RoomAgent, db) -> int:
"""Get the creator's permission level for this agent.
If the agent is linked to a reusable Agent template, use that template's owner.
Otherwise, use the ChatRoom owner's permission.
"""
# If agent is linked to a reusable Agent template, use that template's owner
if agent.agent_id:
template_agent = db.query(Agent).filter(Agent.id == agent.agent_id).first()
if template_agent:
user = db.query(User).filter(User.id == template_agent.user_id).first()
if user:
return user.permission_level
# Fallback to ChatRoom owner
room = db.query(ChatRoom).filter(ChatRoom.id == agent.room_id).first()
if room:
user = db.query(User).filter(User.id == room.user_id).first()
if user:
return user.permission_level
# Default to READ_ONLY if no user found
return CommandPermission.READ_ONLY
async def _agent_turn(
self,
room_id: str,
@ -157,6 +196,9 @@ class ChatRoomOrchestrator:
model = agent.model or llm.default_model or "gpt-4"
# Get creator's permission level for tool execution
creator_permission = self._get_creator_permission_level(agent, db)
# Build messages for this agent
messages = self._build_agent_messages(agent, history)
@ -174,7 +216,7 @@ class ChatRoomOrchestrator:
"round_number": round_num
})
# Stream LLM response
# Stream LLM response (without tools for now - chat room agents are text-only)
try:
async for delta in llm.stream_call(
model=model,
@ -239,6 +281,182 @@ class ChatRoomOrchestrator:
# Close client
await llm.close()
async def _parallel_round(
self,
room_id: str,
agents: List[RoomAgent],
history: List[Dict],
round_num: int,
db
) -> AsyncGenerator[str, None]:
"""Execute all agents in parallel for one round."""
if not agents:
return
# Yield parallel start event
yield sse_event("parallel_start", {
"round": round_num,
"agents": [{"id": a.id, "name": a.name} for a in agents]
})
# Create all agent tasks
tasks = []
for agent in agents:
task = self._agent_turn_async(
room_id, agent, list(history), round_num, db
)
tasks.append(task)
# Execute in parallel and merge streams
async for event in self._merge_streams(tasks):
yield event
# Yield parallel end event
yield sse_event("parallel_end", {
"round": round_num,
"agent_count": len(agents)
})
async def _agent_turn_async(
self,
room_id: str,
agent: RoomAgent,
history: List[Dict],
round_num: int,
db
) -> AsyncGenerator[Dict[str, Any], None]:
"""Execute a single agent turn asynchronously, yielding event stream."""
# Yield agent status - pending
yield {"type": "agent_status", "agent_id": agent.id, "agent_name": agent.name, "status": "pending"}
# Get LLM client for this agent
llm, max_tokens = self._create_llm_client(agent, db)
if not llm:
yield {"type": "agent_error", "agent_id": agent.id, "agent_name": agent.name, "error": "No LLM provider configured"}
return
model = agent.model or llm.default_model or "gpt-4"
# Get creator's permission level for tool execution (for future use)
creator_permission = self._get_creator_permission_level(agent, db)
# Build messages for this agent
messages = self._build_agent_messages(agent, history)
# Create placeholder message for streaming updates
msg_id = generate_id("msg")
accumulated_content = ""
# Yield agent status - streaming
yield {"type": "agent_status", "agent_id": agent.id, "agent_name": agent.name, "status": "streaming"}
# Yield streaming start event with placeholder
yield {"type": "message_start", "id": msg_id, "room_id": room_id, "role": "assistant",
"sender_name": agent.name, "sender_color": agent.color, "round_number": round_num, "agent_id": agent.id}
# Stream LLM response
try:
async for delta in llm.stream_call(
model=model,
messages=messages,
temperature=0.7,
max_tokens=max_tokens or 2000
):
if delta.content:
accumulated_content += delta.content
yield {"type": "message_chunk", "id": msg_id, "content": delta.content,
"accumulated": accumulated_content, "agent_id": agent.id}
if delta.is_complete:
break
except Exception as e:
logger.error(f"LLM stream failed for {agent.name}: {e}")
yield {"type": "agent_error", "agent_id": agent.id, "agent_name": agent.name, "error": f"LLM stream failed: {str(e)}"}
await llm.close()
return
# Estimate token count
token_count = len(accumulated_content) // 4
# Build steps for storage
steps = [{"id": "step-0", "index": 0, "type": "text", "content": accumulated_content}]
content_json = {"steps": steps}
# Save complete message to DB
msg = Message(
id=msg_id,
room_id=room_id,
role="assistant",
content=json.dumps(content_json, ensure_ascii=False),
token_count=token_count,
sender_name=agent.name,
sender_color=agent.color,
round_number=round_num
)
db.add(msg)
db.commit()
# Update history
history.append({"role": "assistant", "content": accumulated_content, "sender": agent.name})
# Yield agent status - completed
yield {"type": "agent_status", "agent_id": agent.id, "agent_name": agent.name, "status": "completed"}
# Yield message end event
yield {"type": "message_end", "id": msg_id, "content": accumulated_content,
"token_count": token_count, "agent_id": agent.id}
# Also yield the complete message for consistency
msg_dict = msg.to_dict()
yield {"type": "message", "message": msg_dict}
# Close client
await llm.close()
async def _merge_streams(
self, tasks: List[AsyncGenerator]
) -> AsyncGenerator[str, None]:
"""Merge multiple streams while maintaining real-time output."""
import asyncio
async def consume_stream(stream, queue):
try:
async for event in stream:
await queue.put(event)
except Exception as e:
logger.error(f"Stream error: {e}")
finally:
await queue.put(None) # Mark end
queue = asyncio.Queue()
consumers = [asyncio.create_task(consume_stream(t, queue)) for t in tasks]
completed = 0
while completed < len(tasks):
event = await queue.get()
if event is None:
completed += 1
else:
# Convert dict event to SSE format
if isinstance(event, dict) and "type" in event:
if event["type"] == "message":
yield sse_event("message", event.get("message", {}))
elif event["type"] == "message_start":
yield sse_event("message_start", {k: v for k, v in event.items() if k != "type"})
elif event["type"] == "message_chunk":
yield sse_event("message_chunk", {k: v for k, v in event.items() if k != "type"})
elif event["type"] == "message_end":
yield sse_event("message_end", {k: v for k, v in event.items() if k != "type"})
elif event["type"] == "agent_status":
yield sse_event("agent_status", {k: v for k, v in event.items() if k != "type"})
elif event["type"] == "agent_error":
yield sse_event("agent_error", {k: v for k, v in event.items() if k != "type"})
else:
yield sse_event(event["type"], {k: v for k, v in event.items() if k != "type"})
# Ensure all tasks complete
await asyncio.gather(*consumers, return_exceptions=True)
def _create_llm_client(self, agent: RoomAgent, db) -> tuple:
"""Create LLM client for an agent."""
if agent.provider_id:

View File

@ -105,6 +105,9 @@ class ToolRegistry:
# Automatic permission check (transparent to tool function)
if context is not None and context.user_id is not None:
user_level = context.extra.get("user_permission_level", CommandPermission.READ_ONLY)
# Ensure user_level is an enum for comparison and display
if isinstance(user_level, int):
user_level = CommandPermission(user_level)
if user_level < tool.required_permission:
return {
"success": False,