debug
This commit is contained in:
parent
77973cc533
commit
a507001aa4
|
|
@ -218,6 +218,25 @@ export const roomsAPI = {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ WebSocket ============
|
// ============ WebSocket ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a WebSocket connection for chat room
|
||||||
|
*
|
||||||
|
* Event Types:
|
||||||
|
* - connected: Connection established
|
||||||
|
* - room_info: Room details with agents
|
||||||
|
* - history: Message history
|
||||||
|
* - agents: Agent list update
|
||||||
|
* - message: New message
|
||||||
|
* - typing: Typing indicator
|
||||||
|
* - stream_start: Streaming response started
|
||||||
|
* - stream_step: Streaming response chunk
|
||||||
|
* - stream_end: Streaming response completed
|
||||||
|
* - stream_error: Streaming error
|
||||||
|
* - system: System message (join/leave)
|
||||||
|
* - error: Generic error
|
||||||
|
* - pong: Heartbeat response
|
||||||
|
*/
|
||||||
export function createRoomWS(roomId, callbacks = {}) {
|
export function createRoomWS(roomId, callbacks = {}) {
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/chat-room/${roomId}${token ? '?token=' + token : ''}`
|
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/chat-room/${roomId}${token ? '?token=' + token : ''}`
|
||||||
|
|
@ -233,31 +252,76 @@ export function createRoomWS(roomId, callbacks = {}) {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
const eventName = msg.event
|
const eventName = msg.event
|
||||||
|
const data = msg.data || {}
|
||||||
|
|
||||||
switch (eventName) {
|
switch (eventName) {
|
||||||
|
// Connection events
|
||||||
case 'connected':
|
case 'connected':
|
||||||
callbacks.onConnected?.(msg.data)
|
callbacks.onConnected?.(data)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'room_info':
|
||||||
|
callbacks.onRoomInfo?.(data.room)
|
||||||
|
break
|
||||||
|
|
||||||
|
// History and messages
|
||||||
case 'history':
|
case 'history':
|
||||||
callbacks.onHistory?.(msg.data.messages)
|
callbacks.onHistory?.(data.messages, data.has_more)
|
||||||
break
|
|
||||||
case 'agents':
|
|
||||||
callbacks.onAgentsUpdate?.(msg.data.agents)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'message':
|
case 'message':
|
||||||
callbacks.onMessage?.(msg.data)
|
// New message received
|
||||||
|
callbacks.onMessage?.(data.message)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
// Agent events
|
||||||
|
case 'agents':
|
||||||
|
callbacks.onAgentsUpdate?.(data.agents, data.count)
|
||||||
|
break
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
case 'typing':
|
case 'typing':
|
||||||
callbacks.onTyping?.(msg.data)
|
callbacks.onTyping?.({
|
||||||
|
sender_id: data.sender_id,
|
||||||
|
sender_type: data.sender_type,
|
||||||
|
agent_name: data.agent_name,
|
||||||
|
is_typing: data.is_typing
|
||||||
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
|
// Streaming events (new format)
|
||||||
|
case 'stream_start':
|
||||||
|
callbacks.onStreamStart?.(data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'stream_step':
|
||||||
|
callbacks.onStreamStep?.(data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'stream_end':
|
||||||
|
callbacks.onStreamEnd?.(data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'stream_error':
|
||||||
|
callbacks.onStreamError?.(data)
|
||||||
|
break
|
||||||
|
|
||||||
|
// Legacy streaming events (for backward compatibility)
|
||||||
case 'process_step':
|
case 'process_step':
|
||||||
case 'done':
|
case 'done':
|
||||||
case 'error':
|
case 'error':
|
||||||
callbacks.onStream?.(eventName, msg.data, msg.agent_id, msg.agent_name)
|
callbacks.onStream?.(eventName, data, data.agent_id, data.agent_name)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
// System events
|
||||||
case 'system':
|
case 'system':
|
||||||
callbacks.onSystem?.(msg.data)
|
callbacks.onSystem?.(data)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'pong':
|
||||||
|
callbacks.onPong?.()
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown event:', eventName, msg)
|
console.log('Unknown event:', eventName, msg)
|
||||||
}
|
}
|
||||||
|
|
@ -282,24 +346,34 @@ export function createRoomWS(roomId, callbacks = {}) {
|
||||||
ws.send(JSON.stringify({ action, ...data }))
|
ws.send(JSON.stringify({ action, ...data }))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sendMessage: (content, userId = 'user', userName = 'User') => {
|
|
||||||
|
sendMessage: (content, userId = 'user', userName = 'User', options = {}) => {
|
||||||
|
const payload = {
|
||||||
|
action: 'send_message',
|
||||||
|
content,
|
||||||
|
user_id: userId,
|
||||||
|
user_name: userName,
|
||||||
|
...options // Support: reply_to, mentions
|
||||||
|
}
|
||||||
|
|
||||||
const send = () => {
|
const send = () => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ action: 'send_message', content, user_id: userId, user_name: userName }))
|
ws.send(JSON.stringify(payload))
|
||||||
} else if (ws.readyState === WebSocket.CONNECTING) {
|
} else if (ws.readyState === WebSocket.CONNECTING) {
|
||||||
// Wait for connection then retry
|
|
||||||
ws.addEventListener('open', () => {
|
ws.addEventListener('open', () => {
|
||||||
ws.send(JSON.stringify({ action: 'send_message', content, user_id: userId, user_name: userName }))
|
ws.send(JSON.stringify(payload))
|
||||||
}, { once: true })
|
}, { once: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
send()
|
send()
|
||||||
},
|
},
|
||||||
|
|
||||||
ping: () => {
|
ping: () => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ action: 'ping' }))
|
ws.send(JSON.stringify({ action: 'ping' }))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
close: () => ws.close()
|
close: () => ws.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,13 @@
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
<button v-if="onEdit" @click.stop="$emit('edit')" class="btn-icon" title="编辑">
|
<button v-if="showEdit" @click.stop="$emit('edit')" class="btn-icon" title="编辑">
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="onDelete" @click.stop="$emit('delete')" class="btn-icon btn-delete-icon" title="删除">
|
<button v-if="showDelete" @click.stop="$emit('delete')" class="btn-icon btn-delete-icon" title="删除">
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||||
|
|
@ -45,8 +45,8 @@ const props = defineProps({
|
||||||
avatarColor: { type: String, default: '#667eea' },
|
avatarColor: { type: String, default: '#667eea' },
|
||||||
active: { type: Boolean, default: false },
|
active: { type: Boolean, default: false },
|
||||||
inRoom: { type: Boolean, default: false },
|
inRoom: { type: Boolean, default: false },
|
||||||
onEdit: { type: Boolean, default: true },
|
showEdit: { type: Boolean, default: true },
|
||||||
onDelete: { type: Boolean, default: true },
|
showDelete: { type: Boolean, default: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['click', 'edit', 'delete'])
|
defineEmits(['click', 'edit', 'delete'])
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="message-bubble" :class="[message.role]">
|
<div class="message-bubble" :class="[messageType]">
|
||||||
<div v-if="message.role === 'user'" class="avatar">user</div>
|
<div v-if="isUser" class="avatar">user</div>
|
||||||
<div v-else class="avatar">Luxx</div>
|
<div v-else class="avatar">{{ senderName }}</div>
|
||||||
<div class="message-container">
|
<div class="message-container">
|
||||||
<!-- File attachments list -->
|
<!-- File attachments list -->
|
||||||
<div v-if="message.attachments && message.attachments.length > 0" class="attachments-list">
|
<div v-if="message.attachments && message.attachments.length > 0" class="attachments-list">
|
||||||
|
|
@ -27,18 +27,18 @@
|
||||||
</div>
|
</div>
|
||||||
<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="isAssistant && 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">{{ 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.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.total">{{ formatNumber(message.usage.total) }} total</span>
|
||||||
</template>
|
</template>
|
||||||
<button v-if="message.role === 'assistant'" class="ghost-btn success" @click="$emit('regenerate', message.id)" title="重新生成">
|
<button v-if="isAssistant" class="ghost-btn success" @click="$emit('regenerate', message.id)" title="重新生成">
|
||||||
<span v-html="regenerateIcon"></span>
|
<span v-html="regenerateIcon"></span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="message.role === 'assistant'" class="ghost-btn accent" @click="copyContent" title="复制">
|
<button v-if="isAssistant" class="ghost-btn accent" @click="copyContent" title="复制">
|
||||||
<span v-html="copyIcon"></span>
|
<span v-html="copyIcon"></span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="deletable" class="ghost-btn danger" @click="$emit('delete', message.id)" title="删除">
|
<button v-if="deletable && isUser" class="ghost-btn danger" @click="$emit('delete', message.id)" title="删除">
|
||||||
<span v-html="trashIcon"></span>
|
<span v-html="trashIcon"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -55,12 +55,21 @@ import ProcessBlock from './ProcessBlock.vue'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
message: { type: Object, required: true },
|
message: { type: Object, required: true },
|
||||||
deletable: { type: Boolean, default: false },
|
deletable: { type: Boolean, default: false },
|
||||||
|
senderName: { type: String, default: 'Luxx' }, // 可配置的发送者名称
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['delete', 'regenerate'])
|
defineEmits(['delete', 'regenerate'])
|
||||||
|
|
||||||
const messageRef = ref(null)
|
const messageRef = ref(null)
|
||||||
|
|
||||||
|
// 支持 sender_type (Room 场景) 和 role (Conversation 场景)
|
||||||
|
const messageType = computed(() => {
|
||||||
|
return props.message.sender_type || props.message.role || 'assistant'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isUser = computed(() => messageType.value === 'user')
|
||||||
|
const isAssistant = computed(() => messageType.value === 'assistant' || messageType.value === 'agent')
|
||||||
|
|
||||||
const renderedContent = computed(() => {
|
const renderedContent = computed(() => {
|
||||||
const text = props.message.content || props.message.text || ''
|
const text = props.message.content || props.message.text || ''
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
|
|
|
||||||
|
|
@ -136,9 +136,10 @@
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<div class="messages-container" ref="messagesContainer">
|
<div class="messages-container" ref="messagesContainer">
|
||||||
<RoomMessageBubble
|
<MessageBubble
|
||||||
v-for="msg in messages" :key="msg.id"
|
v-for="msg in messages" :key="msg.id"
|
||||||
:message="msg"
|
:message="msg"
|
||||||
|
:sender-name="msg.sender_name || 'Luxx'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 正在输入的流式消息 -->
|
<!-- 正在输入的流式消息 -->
|
||||||
|
|
@ -149,10 +150,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="message-container">
|
<div class="message-container">
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<div class="md-content" v-html="renderMarkdown(stream.content)"></div>
|
<ProcessBlock
|
||||||
|
v-if="stream.process_steps && stream.process_steps.length > 0"
|
||||||
|
:process-steps="stream.process_steps"
|
||||||
|
:streaming="true"
|
||||||
|
/>
|
||||||
|
<div v-else class="md-content" v-html="renderMarkdown(stream.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-footer">
|
<div class="message-footer">
|
||||||
<span class="sender-name">{{ stream.agentName }}</span>
|
<span class="message-time">{{ stream.agentName }}</span>
|
||||||
<span class="typing-indicator">正在输入...</span>
|
<span class="typing-indicator">正在输入...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -304,7 +310,8 @@ import { roomsAPI, createRoomWS, agentsAPI, providersAPI } from '@/api'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import ListItem from '@/components/ListItem.vue'
|
import ListItem from '@/components/ListItem.vue'
|
||||||
import InlineForm from '@/components/InlineForm.vue'
|
import InlineForm from '@/components/InlineForm.vue'
|
||||||
import RoomMessageBubble from '@/components/RoomMessageBubble.vue'
|
import MessageBubble from '@/components/MessageBubble.vue'
|
||||||
|
import ProcessBlock from '@/components/ProcessBlock.vue'
|
||||||
|
|
||||||
const rooms = ref([])
|
const rooms = ref([])
|
||||||
const currentRoom = ref(null)
|
const currentRoom = ref(null)
|
||||||
|
|
@ -321,11 +328,15 @@ const editingRoom = ref(null)
|
||||||
const editingAgent = ref(null)
|
const editingAgent = ref(null)
|
||||||
|
|
||||||
const showAddMembers = ref(false)
|
const showAddMembers = ref(false)
|
||||||
|
const showCreateRoom = ref(false)
|
||||||
|
const showRoomManage = ref(false)
|
||||||
|
const roomTab = ref('list')
|
||||||
const inputMessage = ref('')
|
const inputMessage = ref('')
|
||||||
const messagesContainer = ref(null)
|
const messagesContainer = ref(null)
|
||||||
let ws = null
|
let ws = null
|
||||||
|
|
||||||
const roomForm = reactive({ name: '', description: '' })
|
const roomForm = reactive({ name: '', description: '' })
|
||||||
|
const newRoom = reactive({ name: '', description: '' })
|
||||||
|
|
||||||
const agentForm = reactive({
|
const agentForm = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -398,6 +409,43 @@ function closeRoomForm() {
|
||||||
Object.assign(roomForm, { name: '', description: '' })
|
Object.assign(roomForm, { name: '', description: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createRoom() {
|
||||||
|
try {
|
||||||
|
const res = await roomsAPI.create(newRoom)
|
||||||
|
rooms.value.push(res.room)
|
||||||
|
showCreateRoom.value = false
|
||||||
|
Object.assign(newRoom, { name: '', description: '' })
|
||||||
|
joinRoom(res.room)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create room:', e)
|
||||||
|
alert('创建失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRoom(room) {
|
||||||
|
editingRoom.value = room
|
||||||
|
Object.assign(roomForm, { name: room.name, description: room.description || '' })
|
||||||
|
roomTab.value = 'edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRoom(roomId) {
|
||||||
|
if (!confirm('确定删除此聊天室?')) return
|
||||||
|
try {
|
||||||
|
await roomsAPI.delete(roomId)
|
||||||
|
rooms.value = rooms.value.filter(r => r.id !== roomId)
|
||||||
|
if (currentRoom.value?.id === roomId) {
|
||||||
|
currentRoom.value = null
|
||||||
|
messages.value = []
|
||||||
|
}
|
||||||
|
if (roomTab.value === 'list') {
|
||||||
|
// Refresh room list in modal
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete room:', e)
|
||||||
|
alert('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveRoom() {
|
async function saveRoom() {
|
||||||
try {
|
try {
|
||||||
if (editingRoom.value) {
|
if (editingRoom.value) {
|
||||||
|
|
@ -445,39 +493,113 @@ async function joinRoom(room) {
|
||||||
streamingMessages.value = {}
|
streamingMessages.value = {}
|
||||||
typingAgents.value = new Set()
|
typingAgents.value = new Set()
|
||||||
|
|
||||||
// 加载房间成员
|
// WebSocket 连接 - 使用新的事件格式
|
||||||
try {
|
|
||||||
const res = await roomsAPI.listAgents(room.id)
|
|
||||||
roomAgents.value = res.agents || []
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load room agents:', e)
|
|
||||||
roomAgents.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket 连接
|
|
||||||
ws = createRoomWS(room.id, {
|
ws = createRoomWS(room.id, {
|
||||||
onConnected: () => console.log('Connected to room'),
|
onConnect: () => console.log('Connecting to room...'),
|
||||||
onHistory: (msgs) => {
|
|
||||||
|
onConnected: (data) => {
|
||||||
|
console.log('Connected to room:', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
onRoomInfo: (roomData) => {
|
||||||
|
currentRoom.value = roomData
|
||||||
|
},
|
||||||
|
|
||||||
|
onHistory: (msgs, hasMore) => {
|
||||||
|
// 新格式: messages 直接是数组
|
||||||
messages.value = msgs || []
|
messages.value = msgs || []
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
},
|
},
|
||||||
onAgentsUpdate: (agents) => {
|
|
||||||
|
onAgentsUpdate: (agents, count) => {
|
||||||
|
// 新格式: agents 是对象数组
|
||||||
roomAgents.value = agents || []
|
roomAgents.value = agents || []
|
||||||
},
|
},
|
||||||
|
|
||||||
onMessage: (msg) => {
|
onMessage: (msg) => {
|
||||||
messages.value.push(msg)
|
// 新格式: msg 是 { message: {...} } 结构,需要提取 message
|
||||||
|
const message = msg.message || msg
|
||||||
|
messages.value.push(message)
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
},
|
},
|
||||||
|
|
||||||
onTyping: (data) => {
|
onTyping: (data) => {
|
||||||
|
// 新格式: {sender_id, sender_type, agent_name, is_typing}
|
||||||
if (data.is_typing) {
|
if (data.is_typing) {
|
||||||
typingAgents.value.add(data.agent_id)
|
typingAgents.value.add(data.sender_id)
|
||||||
} else {
|
} else {
|
||||||
typingAgents.value.delete(data.agent_id)
|
typingAgents.value.delete(data.sender_id)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 新的流式响应处理
|
||||||
|
onStreamStart: (data) => {
|
||||||
|
// {stream_id, message_id, agent, parent_message_id}
|
||||||
|
const agentId = data.agent?.id
|
||||||
|
const agentName = data.agent?.name || 'Agent'
|
||||||
|
streamingMessages.value[agentId] = {
|
||||||
|
streamId: data.stream_id,
|
||||||
|
agentName,
|
||||||
|
content: '',
|
||||||
|
steps: []
|
||||||
|
}
|
||||||
|
typingAgents.value.add(agentId)
|
||||||
|
},
|
||||||
|
|
||||||
|
onStreamStep: (data) => {
|
||||||
|
// {stream_id, step: {id, type, delta, full, done}}
|
||||||
|
const streamData = streamingMessages.value[data.stream_id] || streamingMessages.value[Object.keys(streamingMessages.value)[0]]
|
||||||
|
if (!streamData) return
|
||||||
|
|
||||||
|
const step = data.step
|
||||||
|
if (step.type === 'text' || step.type === 'thinking') {
|
||||||
|
streamData.content = step.full
|
||||||
|
}
|
||||||
|
if (step.arguments !== undefined) {
|
||||||
|
// Tool call step
|
||||||
|
if (!streamData.toolCalls) streamData.toolCalls = []
|
||||||
|
streamData.toolCalls.push(step)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onStreamEnd: (data) => {
|
||||||
|
// {stream_id, content, token_count, usage}
|
||||||
|
const agentId = Object.keys(streamingMessages.value).find(
|
||||||
|
key => streamingMessages.value[key].streamId === data.stream_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (agentId && streamingMessages.value[agentId]) {
|
||||||
|
const streamData = streamingMessages.value[agentId]
|
||||||
|
messages.value.push({
|
||||||
|
id: `msg-${Date.now()}`,
|
||||||
|
sender_type: 'agent',
|
||||||
|
sender_id: agentId,
|
||||||
|
sender_name: streamData.agentName,
|
||||||
|
content: data.content || streamData.content,
|
||||||
|
token_count: data.token_count,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
delete streamingMessages.value[agentId]
|
||||||
|
}
|
||||||
|
typingAgents.value.delete(agentId)
|
||||||
|
scrollToBottom()
|
||||||
|
},
|
||||||
|
|
||||||
|
onStreamError: (data) => {
|
||||||
|
// {stream_id, error}
|
||||||
|
console.error('Stream error:', data.error)
|
||||||
|
const agentId = Object.keys(streamingMessages.value).find(
|
||||||
|
key => streamingMessages.value[key].streamId === data.stream_id
|
||||||
|
)
|
||||||
|
if (agentId) {
|
||||||
|
delete streamingMessages.value[agentId]
|
||||||
|
typingAgents.value.delete(agentId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 兼容旧的流式格式
|
||||||
onStream: (event, data, agentId, agentName) => {
|
onStream: (event, data, agentId, agentName) => {
|
||||||
if (event === 'process_step') {
|
if (event === 'process_step') {
|
||||||
// data.step contains {id, index, type, content}
|
|
||||||
const step = data.step || data
|
const step = data.step || data
|
||||||
if (!streamingMessages.value[agentId]) {
|
if (!streamingMessages.value[agentId]) {
|
||||||
streamingMessages.value[agentId] = { agentName, content: '' }
|
streamingMessages.value[agentId] = { agentName, content: '' }
|
||||||
|
|
@ -486,7 +608,6 @@ async function joinRoom(room) {
|
||||||
streamingMessages.value[agentId].content = step.content
|
streamingMessages.value[agentId].content = step.content
|
||||||
}
|
}
|
||||||
} else if (event === 'done') {
|
} else if (event === 'done') {
|
||||||
// Save streaming message to messages list before removing
|
|
||||||
if (streamingMessages.value[agentId]) {
|
if (streamingMessages.value[agentId]) {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: `msg-${Date.now()}`,
|
id: `msg-${Date.now()}`,
|
||||||
|
|
@ -501,8 +622,16 @@ async function joinRoom(room) {
|
||||||
typingAgents.value.delete(agentId)
|
typingAgents.value.delete(agentId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onSystem: (data) => {
|
onSystem: (data) => {
|
||||||
console.log('System:', data)
|
// {type, sender, content, message}
|
||||||
|
console.log('System event:', data)
|
||||||
|
if (data.message) {
|
||||||
|
// 提取 message 对象
|
||||||
|
const message = data.message.message || data.message
|
||||||
|
messages.value.push(message)
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ class UpdateChatRoomRequest(BaseModel):
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
class SendMessageRequest(BaseModel):
|
||||||
content: str
|
content: str
|
||||||
|
reply_to: Optional[str] = None # Message ID to reply to
|
||||||
|
mentions: Optional[List[str]] = None # Mentioned agent IDs
|
||||||
|
|
||||||
|
|
||||||
class AddAgentRequest(BaseModel):
|
class AddAgentRequest(BaseModel):
|
||||||
|
|
@ -56,7 +58,7 @@ async def create_chat_room(request: CreateChatRoomRequest):
|
||||||
|
|
||||||
@router.get("/{room_id}")
|
@router.get("/{room_id}")
|
||||||
async def get_chat_room(room_id: str):
|
async def get_chat_room(room_id: str):
|
||||||
"""Get a chat room by ID"""
|
"""Get a chat room by ID with agents"""
|
||||||
room = chat_room_service.get_room(room_id)
|
room = chat_room_service.get_room(room_id)
|
||||||
if not room:
|
if not room:
|
||||||
raise HTTPException(status_code=404, detail="Chat room not found")
|
raise HTTPException(status_code=404, detail="Chat room not found")
|
||||||
|
|
@ -79,7 +81,7 @@ async def update_chat_room(room_id: str, request: UpdateChatRoomRequest):
|
||||||
|
|
||||||
@router.delete("/{room_id}")
|
@router.delete("/{room_id}")
|
||||||
async def delete_chat_room(room_id: str):
|
async def delete_chat_room(room_id: str):
|
||||||
"""Delete a chat room"""
|
"""Delete a chat room and all related data"""
|
||||||
success = chat_room_service.delete_room(room_id)
|
success = chat_room_service.delete_room(room_id)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=404, detail="Chat room not found")
|
raise HTTPException(status_code=404, detail="Chat room not found")
|
||||||
|
|
@ -88,9 +90,23 @@ async def delete_chat_room(room_id: str):
|
||||||
|
|
||||||
@router.get("/{room_id}/agents")
|
@router.get("/{room_id}/agents")
|
||||||
async def get_room_agents(room_id: str):
|
async def get_room_agents(room_id: str):
|
||||||
"""Get all agents in a chat room"""
|
"""Get all agents in a chat room (from stable RoomAgent table)"""
|
||||||
|
# Return both BaseAgent objects and info from RoomAgent table
|
||||||
agents = chat_room_service.get_room_agents(room_id)
|
agents = chat_room_service.get_room_agents(room_id)
|
||||||
return {"agents": [a.to_dict() for a in agents]}
|
agents_info = chat_room_service.get_room_agents_info(room_id)
|
||||||
|
|
||||||
|
# Merge agent data
|
||||||
|
agent_data = []
|
||||||
|
for agent in agents:
|
||||||
|
agent_dict = agent.to_dict()
|
||||||
|
# Find matching info
|
||||||
|
for info in agents_info:
|
||||||
|
if info.get("id") == agent.agent_id:
|
||||||
|
agent_dict.update(info)
|
||||||
|
break
|
||||||
|
agent_data.append(agent_dict)
|
||||||
|
|
||||||
|
return {"agents": agent_data, "count": len(agent_data)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{room_id}/agents")
|
@router.post("/{room_id}/agents")
|
||||||
|
|
@ -99,7 +115,9 @@ async def add_agent_to_room(room_id: str, request: AddAgentRequest):
|
||||||
success = chat_room_service.add_agent_to_room(room_id, request.agent_id)
|
success = chat_room_service.add_agent_to_room(room_id, request.agent_id)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=400, detail="Failed to add agent")
|
raise HTTPException(status_code=400, detail="Failed to add agent")
|
||||||
return {"success": True}
|
# Return updated agents list
|
||||||
|
agents = chat_room_service.get_room_agents_info(room_id)
|
||||||
|
return {"success": True, "agents": agents}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{room_id}/agents/{agent_id}")
|
@router.delete("/{room_id}/agents/{agent_id}")
|
||||||
|
|
@ -115,12 +133,15 @@ async def remove_agent_from_room(room_id: str, agent_id: str):
|
||||||
async def get_room_messages(room_id: str, limit: int = 50, before_id: str = None):
|
async def get_room_messages(room_id: str, limit: int = 50, before_id: str = None):
|
||||||
"""Get messages from a chat room"""
|
"""Get messages from a chat room"""
|
||||||
messages = chat_room_service.get_messages(room_id, limit=limit, before_id=before_id)
|
messages = chat_room_service.get_messages(room_id, limit=limit, before_id=before_id)
|
||||||
return {"messages": messages}
|
return {"messages": messages, "count": len(messages)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{room_id}/messages")
|
@router.post("/{room_id}/messages")
|
||||||
async def send_message(room_id: str, request: SendMessageRequest):
|
async def send_message(room_id: str, request: SendMessageRequest):
|
||||||
"""Send a message to a chat room. Returns a streaming response via SSE."""
|
"""Send a message to a chat room. Returns a streaming response via SSE.
|
||||||
|
|
||||||
|
This endpoint is for HTTP-based messaging. WebSocket is preferred for real-time chat.
|
||||||
|
"""
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
@ -128,21 +149,27 @@ async def send_message(room_id: str, request: SendMessageRequest):
|
||||||
user_name = "User"
|
user_name = "User"
|
||||||
|
|
||||||
async def generate():
|
async def generate():
|
||||||
|
# Save user message first
|
||||||
|
user_msg = chat_room_service.save_message(
|
||||||
|
room_id=room_id,
|
||||||
|
sender_type="user",
|
||||||
|
sender_name=user_name,
|
||||||
|
content=request.content,
|
||||||
|
sender_id=user_id,
|
||||||
|
mentions=request.mentions,
|
||||||
|
parent_id=request.reply_to
|
||||||
|
)
|
||||||
|
|
||||||
|
# Yield saved message event
|
||||||
|
yield f"data: {json.dumps({'event': 'message', 'data': {'message': user_msg}}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
# Process and stream agent responses
|
||||||
async for event in chat_room_service.process_message(
|
async for event in chat_room_service.process_message(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user_message=request.content,
|
user_message=request.content,
|
||||||
user_id=user_id,
|
sender_id=user_id,
|
||||||
user_name=user_name
|
sender_name=user_name
|
||||||
):
|
):
|
||||||
if event.get("event") in ["process_step", "done"]:
|
|
||||||
chat_room_service.save_message(
|
|
||||||
room_id=room_id,
|
|
||||||
sender_type="user",
|
|
||||||
sender_id=user_id,
|
|
||||||
sender_name=user_name,
|
|
||||||
content=request.content
|
|
||||||
)
|
|
||||||
|
|
||||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"""Models package"""
|
"""Models package"""
|
||||||
from luxx.models.user import User, LLMProvider, Project
|
from luxx.models.user import User, LLMProvider, Project
|
||||||
from luxx.models.chat import Conversation, Message
|
from luxx.models.chat import Conversation, Message
|
||||||
from luxx.models.room import ChatRoom, Agent
|
from luxx.models.room import ChatRoom, Agent, RoomAgent
|
||||||
from luxx.models.participant import Participant, ParticipantType
|
from luxx.models.participant import Participant, ParticipantType
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "LLMProvider", "Project",
|
"User", "LLMProvider", "Project",
|
||||||
"Conversation", "Message",
|
"Conversation", "Message",
|
||||||
"ChatRoom", "Agent",
|
"ChatRoom", "Agent", "RoomAgent",
|
||||||
"Participant", "ParticipantType",
|
"Participant", "ParticipantType",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -58,17 +58,34 @@ class Conversation(Base):
|
||||||
class Message(Base):
|
class Message(Base):
|
||||||
"""Unified Message model for Conversation and ChatRoom.
|
"""Unified Message model for Conversation and ChatRoom.
|
||||||
|
|
||||||
role: user/assistant/system/tool
|
统一消息模型,支持:
|
||||||
content: JSON format with text, attachments, tool_calls, steps
|
- Conversation: 单人会话
|
||||||
|
- ChatRoom: 聊天室(多 Agent)
|
||||||
|
|
||||||
|
sender_type: user | agent | system
|
||||||
|
content: JSON 格式 {"text": "...", "steps": [...]}
|
||||||
"""
|
"""
|
||||||
__tablename__ = "messages"
|
__tablename__ = "messages"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
conversation_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=True)
|
conversation_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=True)
|
||||||
room_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
room_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
||||||
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
# 发送者信息
|
||||||
|
sender_id: Mapped[str] = mapped_column(String(64), nullable=False, default="") # 用户ID 或 AgentID
|
||||||
|
sender_type: Mapped[str] = mapped_column(String(16), nullable=False, default="user") # "user" | "agent" | "system"
|
||||||
sender_name: Mapped[str] = mapped_column(String(50), nullable=False, default="")
|
sender_name: Mapped[str] = mapped_column(String(50), nullable=False, default="")
|
||||||
|
|
||||||
|
# 消息内容(兼容旧格式,同时保留 role 字段用于兼容 Conversation)
|
||||||
|
role: Mapped[str] = mapped_column(String(16), nullable=False, default="user") # 保留,兼容 Conversation
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
|
||||||
|
# 流式响应元数据
|
||||||
|
is_streaming: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
stream_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
parent_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) # 回复的消息ID
|
||||||
|
|
||||||
|
# 元数据
|
||||||
mentions: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
mentions: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
token_count: Mapped[int] = mapped_column(Integer, default=0)
|
token_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
usage: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
usage: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
@ -84,19 +101,37 @@ class Message(Base):
|
||||||
def target_id(self) -> str:
|
def target_id(self) -> str:
|
||||||
return self.conversation_id or self.room_id or ""
|
return self.conversation_id or self.room_id or ""
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self, include_stream_data: bool = False):
|
||||||
|
"""转换消息为字典
|
||||||
|
|
||||||
|
统一使用 sender 格式,content 保留 JSON 和纯文本两种格式
|
||||||
|
"""
|
||||||
result = {
|
result = {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"conversation_id": self.conversation_id,
|
"conversation_id": self.conversation_id,
|
||||||
"room_id": self.room_id,
|
"room_id": self.room_id,
|
||||||
"target_type": self.target_type,
|
"target_type": self.target_type,
|
||||||
"target_id": self.target_id,
|
"target_id": self.target_id,
|
||||||
"role": self.role,
|
"sender": {
|
||||||
|
"id": self.sender_id,
|
||||||
|
"type": self.sender_type,
|
||||||
|
"name": self.sender_name
|
||||||
|
},
|
||||||
|
# 兼容字段
|
||||||
|
"sender_id": self.sender_id,
|
||||||
|
"sender_type": self.sender_type,
|
||||||
"sender_name": self.sender_name,
|
"sender_name": self.sender_name,
|
||||||
|
"role": self.role, # 保留,兼容 Conversation
|
||||||
"token_count": self.token_count,
|
"token_count": self.token_count,
|
||||||
|
"is_streaming": self.is_streaming,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 流式数据
|
||||||
|
if include_stream_data:
|
||||||
|
result["stream_id"] = self.stream_id
|
||||||
|
result["parent_id"] = self.parent_id
|
||||||
|
|
||||||
# Parse usage JSON
|
# Parse usage JSON
|
||||||
if self.usage:
|
if self.usage:
|
||||||
try:
|
try:
|
||||||
|
|
@ -113,22 +148,20 @@ class Message(Base):
|
||||||
else:
|
else:
|
||||||
result["mentions"] = []
|
result["mentions"] = []
|
||||||
|
|
||||||
# Parse content JSON
|
# Parse content JSON - 提取 text 和 steps
|
||||||
try:
|
try:
|
||||||
content_obj = json.loads(self.content) if self.content else {}
|
content_obj = json.loads(self.content) if self.content else {}
|
||||||
|
result["content"] = content_obj.get("text", content_obj.get("content", self.content))
|
||||||
|
result["text"] = result["content"]
|
||||||
|
result["process_steps"] = content_obj.get("steps", content_obj.get("process_steps", []))
|
||||||
|
result["attachments"] = content_obj.get("attachments", [])
|
||||||
|
result["tool_calls"] = content_obj.get("tool_calls", [])
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
# 纯文本内容
|
||||||
result["text"] = self.content
|
result["text"] = self.content
|
||||||
|
result["content"] = self.content
|
||||||
|
result["process_steps"] = []
|
||||||
result["attachments"] = []
|
result["attachments"] = []
|
||||||
result["tool_calls"] = []
|
result["tool_calls"] = []
|
||||||
result["process_steps"] = []
|
|
||||||
return result
|
|
||||||
|
|
||||||
result["text"] = content_obj.get("text", "")
|
|
||||||
result["attachments"] = content_obj.get("attachments", [])
|
|
||||||
result["tool_calls"] = content_obj.get("tool_calls", [])
|
|
||||||
result["process_steps"] = content_obj.get("steps", [])
|
|
||||||
|
|
||||||
if "content" not in content_obj:
|
|
||||||
result["content"] = result["text"]
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""ChatRoom models - unified participant architecture"""
|
"""ChatRoom models - unified participant architecture"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List, TYPE_CHECKING
|
from typing import Optional, List, TYPE_CHECKING
|
||||||
from sqlalchemy import String, Integer, Boolean, Text, DateTime, ForeignKey
|
from sqlalchemy import String, Integer, Boolean, Text, DateTime, ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from luxx.core.database import Base
|
from luxx.core.database import Base
|
||||||
|
|
@ -27,14 +27,44 @@ class ChatRoom(Base):
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
|
||||||
|
|
||||||
owner: Mapped["User"] = relationship("User", backref="chat_rooms")
|
owner: Mapped["User"] = relationship("User", backref="chat_rooms")
|
||||||
|
room_agents: Mapped[List["RoomAgent"]] = relationship("RoomAgent", back_populates="room", cascade="all, delete-orphan")
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self, include_agents: bool = False):
|
||||||
return {
|
result = {
|
||||||
"id": self.id, "name": self.name, "description": self.description,
|
"id": self.id, "name": self.name, "description": self.description,
|
||||||
"owner_id": self.owner_id, "is_active": self.is_active,
|
"owner_id": self.owner_id, "is_active": self.is_active,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
||||||
}
|
}
|
||||||
|
if include_agents:
|
||||||
|
result["agents"] = [ra.to_dict() for ra in self.room_agents]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class RoomAgent(Base):
|
||||||
|
"""ChatRoom 与 Agent 的关联表(替代依赖 Message 表的不稳定方案)"""
|
||||||
|
__tablename__ = "room_agents"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('room_id', 'agent_id', name='uq_room_agent'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
room_id: Mapped[str] = mapped_column(String(64), ForeignKey("chat_rooms.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
agent_id: Mapped[str] = mapped_column(String(64), ForeignKey("agents.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
joined_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
room: Mapped["ChatRoom"] = relationship("ChatRoom", back_populates="room_agents")
|
||||||
|
agent: Mapped["Agent"] = relationship("Agent")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.agent_id,
|
||||||
|
"room_agent_id": self.id,
|
||||||
|
"joined_at": self.joined_at.isoformat() if self.joined_at else None,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"agent": self.agent.to_dict() if self.agent else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Agent(Base):
|
class Agent(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Participant Service - unified service for users and agents in chat rooms."""
|
"""Participant Service - unified service for users and agents in chat rooms."""
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional, AsyncGenerator
|
from typing import Dict, Any, Optional, AsyncGenerator, List
|
||||||
|
|
||||||
from luxx.agents.base import BaseAgent
|
from luxx.agents.base import BaseAgent
|
||||||
from luxx.agents.registry import agent_registry
|
from luxx.agents.registry import agent_registry
|
||||||
|
|
@ -25,11 +25,11 @@ class ParticipantService:
|
||||||
# ==================== Agent ====================
|
# ==================== Agent ====================
|
||||||
|
|
||||||
def register_agent(self, agent: BaseAgent) -> Participant:
|
def register_agent(self, agent: BaseAgent) -> Participant:
|
||||||
|
"""Register an active agent in the participant service"""
|
||||||
self._active_agents[agent.agent_id] = agent
|
self._active_agents[agent.agent_id] = agent
|
||||||
agent_registry.register(agent)
|
agent_registry.register(agent)
|
||||||
return Participant.from_agent(
|
return Participant.from_agent(
|
||||||
agent.agent_id, agent.name, agent.role, agent.avatar,
|
agent.agent_id, agent.name, agent.role, agent.avatar
|
||||||
agent.auto_response, agent.mention_trigger, agent.priority
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def unregister_agent(self, agent_id: str) -> bool:
|
def unregister_agent(self, agent_id: str) -> bool:
|
||||||
|
|
@ -40,13 +40,13 @@ class ParticipantService:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_agent_participant(self, agent_id: str) -> Optional[Participant]:
|
def get_agent_participant(self, agent_id: str) -> Optional[Participant]:
|
||||||
|
"""Get agent participant info"""
|
||||||
agent = self._active_agents.get(agent_id) or chat_room_service.get_agent(agent_id)
|
agent = self._active_agents.get(agent_id) or chat_room_service.get_agent(agent_id)
|
||||||
if agent:
|
if agent:
|
||||||
if agent_id not in self._active_agents:
|
if agent_id not in self._active_agents:
|
||||||
self._active_agents[agent_id] = agent
|
self._active_agents[agent_id] = agent
|
||||||
return Participant.from_agent(
|
return Participant.from_agent(
|
||||||
agent.agent_id, agent.name, agent.role, agent.avatar,
|
agent.agent_id, agent.name, agent.role, agent.avatar
|
||||||
agent.auto_response, agent.mention_trigger, agent.priority
|
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -86,42 +86,76 @@ class ParticipantService:
|
||||||
self, room_id: str, content: str, sender_id: str,
|
self, room_id: str, content: str, sender_id: str,
|
||||||
sender_name: str, sender_type: str = "user", context: Dict = None
|
sender_name: str, sender_type: str = "user", context: Dict = None
|
||||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||||
|
"""Process a message in a chat room
|
||||||
|
|
||||||
|
This is a wrapper around chat_room_service.process_message
|
||||||
|
that handles broadcasting and typing indicators.
|
||||||
|
"""
|
||||||
cm = self._cm()
|
cm = self._cm()
|
||||||
|
|
||||||
msg = chat_room_service.save_message(room_id, sender_type, sender_id, sender_name, content)
|
# Save and broadcast message
|
||||||
await cm.broadcast_to_room(room_id, {"event": "message", "data": msg})
|
msg = chat_room_service.save_message(
|
||||||
|
room_id=room_id,
|
||||||
|
sender_type=sender_type,
|
||||||
|
sender_name=sender_name,
|
||||||
|
content=content,
|
||||||
|
sender_id=str(sender_id)
|
||||||
|
)
|
||||||
|
await cm.broadcast_to_room(room_id, {"event": "message", "data": {"message": msg}})
|
||||||
|
|
||||||
|
# Get room agents
|
||||||
room_agents = chat_room_service.get_room_agents(room_id)
|
room_agents = chat_room_service.get_room_agents(room_id)
|
||||||
if sender_type == "agent":
|
if sender_type == "agent":
|
||||||
room_agents = [a for a in room_agents if a.agent_id != sender_id]
|
room_agents = [a for a in room_agents if a.agent_id != sender_id]
|
||||||
|
|
||||||
|
# Broadcast typing indicators
|
||||||
for agent in room_agents:
|
for agent in room_agents:
|
||||||
await cm.broadcast_to_room(room_id, {
|
await cm.broadcast_to_room(room_id, {
|
||||||
"event": "typing",
|
"event": "typing",
|
||||||
"data": {"agent_id": agent.agent_id, "agent_name": agent.name, "is_typing": True}
|
"data": {
|
||||||
|
"sender_id": agent.agent_id,
|
||||||
|
"sender_type": "agent",
|
||||||
|
"agent_name": agent.name,
|
||||||
|
"is_typing": True
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Process and yield events
|
||||||
ctx = (context or {})
|
ctx = (context or {})
|
||||||
ctx.update({"sender_type": sender_type, "sender_id": sender_id, "username": sender_name})
|
ctx.update({"sender_type": sender_type, "sender_id": sender_id, "username": sender_name})
|
||||||
|
|
||||||
async for event in chat_room_service.process_message(room_id, content, sender_id, sender_name, ctx):
|
async for event in chat_room_service.process_message(room_id, content, sender_id, sender_name, ctx):
|
||||||
yield event
|
yield event
|
||||||
|
|
||||||
|
# Clear typing indicators
|
||||||
for agent in room_agents:
|
for agent in room_agents:
|
||||||
await cm.broadcast_to_room(room_id, {
|
await cm.broadcast_to_room(room_id, {
|
||||||
"event": "typing",
|
"event": "typing",
|
||||||
"data": {"agent_id": agent.agent_id, "agent_name": agent.name, "is_typing": False}
|
"data": {
|
||||||
|
"sender_id": agent.agent_id,
|
||||||
|
"sender_type": "agent",
|
||||||
|
"agent_name": agent.name,
|
||||||
|
"is_typing": False
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async def send_message(
|
async def send_message(
|
||||||
self, room_id: str, participant_id: str,
|
self, room_id: str, participant_id: str,
|
||||||
participant_type: str, participant_name: str, content: str
|
participant_type: str, participant_name: str, content: str,
|
||||||
|
mentions: List[str] = None, parent_id: str = None
|
||||||
):
|
):
|
||||||
|
"""Send a message as a participant"""
|
||||||
cm = self._cm()
|
cm = self._cm()
|
||||||
msg = chat_room_service.save_message(
|
msg = chat_room_service.save_message(
|
||||||
room_id, participant_type, participant_id, participant_name, content
|
room_id=room_id,
|
||||||
|
sender_type=participant_type,
|
||||||
|
sender_name=participant_name,
|
||||||
|
content=content,
|
||||||
|
sender_id=str(participant_id),
|
||||||
|
mentions=mentions,
|
||||||
|
parent_id=parent_id
|
||||||
)
|
)
|
||||||
await cm.broadcast_to_room(room_id, {"event": "message", "data": msg})
|
await cm.broadcast_to_room(room_id, {"event": "message", "data": {"message": msg}})
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any, Optional, AsyncGenerator
|
from typing import List, Dict, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from luxx.core.database import SessionLocal
|
from luxx.core.database import SessionLocal
|
||||||
from luxx.models.room import ChatRoom, Agent
|
from luxx.models.room import ChatRoom, Agent, RoomAgent
|
||||||
from luxx.models.chat import Message
|
from luxx.models.chat import Message
|
||||||
from luxx.agents.base import BaseAgent
|
from luxx.agents.base import BaseAgent
|
||||||
|
|
||||||
|
|
@ -21,41 +24,42 @@ class ChatRoomService:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def get_room_agents(self, room_id: str) -> List[BaseAgent]:
|
def get_room_agents(self, room_id: str) -> List[BaseAgent]:
|
||||||
"""Get active agents in a room from Message table"""
|
"""Get active agents in a room from RoomAgent association table"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# Query distinct agent records from messages
|
# Query from RoomAgent table (stable approach) with eager loading
|
||||||
messages = db.query(Message).filter(
|
room_agents = db.query(RoomAgent).options(
|
||||||
Message.room_id == room_id,
|
joinedload(RoomAgent.agent)
|
||||||
Message.role == "agent"
|
).filter(
|
||||||
).distinct().all()
|
RoomAgent.room_id == room_id,
|
||||||
|
RoomAgent.is_active == True
|
||||||
agent_ids = []
|
).all()
|
||||||
seen = set()
|
|
||||||
for msg in messages:
|
|
||||||
# Extract agent_id from content JSON
|
|
||||||
try:
|
|
||||||
content = json.loads(msg.content) if msg.content else {}
|
|
||||||
agent_id = content.get("agent_id")
|
|
||||||
if agent_id and agent_id not in seen:
|
|
||||||
seen.add(agent_id)
|
|
||||||
agent_ids.append(agent_id)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
agents = []
|
agents = []
|
||||||
for agent_id in agent_ids:
|
for ra in room_agents:
|
||||||
agent_db = db.query(Agent).filter(
|
if ra.agent and ra.agent.is_active:
|
||||||
Agent.id == agent_id,
|
agents.append(BaseAgent.from_model(ra.agent))
|
||||||
Agent.is_active == True
|
|
||||||
).first()
|
|
||||||
if agent_db:
|
|
||||||
agents.append(BaseAgent.from_model(agent_db))
|
|
||||||
|
|
||||||
return sorted(agents, key=lambda a: a.priority)
|
return sorted(agents, key=lambda a: a.priority)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
def get_room_agents_info(self, room_id: str) -> List[Dict]:
|
||||||
|
"""Get room agents info with join metadata (using eager loading)"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Use joinedload to eager load agent relationship
|
||||||
|
room_agents = db.query(RoomAgent).options(
|
||||||
|
joinedload(RoomAgent.agent)
|
||||||
|
).filter(
|
||||||
|
RoomAgent.room_id == room_id,
|
||||||
|
RoomAgent.is_active == True
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return [ra.to_dict() for ra in room_agents]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
def get_agent(self, agent_id: str) -> Optional[BaseAgent]:
|
def get_agent(self, agent_id: str) -> Optional[BaseAgent]:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|
@ -75,6 +79,7 @@ class ChatRoomService:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def create_room(self, name: str, owner_id: int, description: str = None, agent_ids: List[str] = None) -> Dict:
|
def create_room(self, name: str, owner_id: int, description: str = None, agent_ids: List[str] = None) -> Dict:
|
||||||
|
"""Create a new chat room with optional initial agents"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
room = ChatRoom(
|
room = ChatRoom(
|
||||||
|
|
@ -85,19 +90,31 @@ class ChatRoomService:
|
||||||
)
|
)
|
||||||
db.add(room)
|
db.add(room)
|
||||||
|
|
||||||
# Record agents as join messages
|
# Add agents using RoomAgent association table
|
||||||
for agent_id in (agent_ids or []):
|
for agent_id in (agent_ids or []):
|
||||||
msg = Message(
|
# Check if agent exists
|
||||||
id=str(uuid.uuid4()),
|
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||||
room_id=room.id,
|
if agent:
|
||||||
role="agent",
|
room_agent = RoomAgent(
|
||||||
content=json.dumps({"type": "join", "agent_id": agent_id}),
|
room_id=room.id,
|
||||||
sender_name=agent_id
|
agent_id=agent_id
|
||||||
)
|
)
|
||||||
db.add(msg)
|
db.add(room_agent)
|
||||||
|
|
||||||
|
# Record system message
|
||||||
|
msg = Message(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
room_id=room.id,
|
||||||
|
sender_id=agent_id,
|
||||||
|
sender_type="system",
|
||||||
|
sender_name="System",
|
||||||
|
role="system",
|
||||||
|
content=json.dumps({"type": "agent_join", "agent_id": agent_id, "agent_name": agent.name})
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return room.to_dict()
|
return room.to_dict(include_agents=True)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
@ -116,10 +133,14 @@ class ChatRoomService:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def delete_room(self, room_id: str) -> bool:
|
def delete_room(self, room_id: str) -> bool:
|
||||||
|
"""Delete a chat room and all related data"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
|
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
|
||||||
if room:
|
if room:
|
||||||
|
# Delete related messages
|
||||||
|
db.query(Message).filter(Message.room_id == room_id).delete()
|
||||||
|
# RoomAgent will be cascade deleted due to relationship config
|
||||||
db.delete(room)
|
db.delete(room)
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
@ -160,6 +181,94 @@ class ChatRoomService:
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
def add_agent_to_room(self, room_id: str, agent_id: str) -> bool:
|
||||||
|
"""Add an agent to a chat room using RoomAgent association table"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
|
||||||
|
if not room:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if agent exists
|
||||||
|
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||||
|
if not agent:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if already in room
|
||||||
|
existing = db.query(RoomAgent).filter(
|
||||||
|
RoomAgent.room_id == room_id,
|
||||||
|
RoomAgent.agent_id == agent_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Reactivate if was removed
|
||||||
|
if not existing.is_active:
|
||||||
|
existing.is_active = True
|
||||||
|
existing.joined_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Add new association
|
||||||
|
room_agent = RoomAgent(
|
||||||
|
room_id=room_id,
|
||||||
|
agent_id=agent_id
|
||||||
|
)
|
||||||
|
db.add(room_agent)
|
||||||
|
|
||||||
|
# Record system message
|
||||||
|
msg = Message(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
room_id=room_id,
|
||||||
|
sender_id=agent_id,
|
||||||
|
sender_type="system",
|
||||||
|
sender_name="System",
|
||||||
|
role="system",
|
||||||
|
content=json.dumps({"type": "agent_join", "agent_id": agent_id, "agent_name": agent.name})
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to add agent to room: {e}")
|
||||||
|
db.rollback()
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def remove_agent_from_room(self, room_id: str, agent_id: str) -> bool:
|
||||||
|
"""Remove an agent from a chat room"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Soft delete: mark as inactive
|
||||||
|
result = db.query(RoomAgent).filter(
|
||||||
|
RoomAgent.room_id == room_id,
|
||||||
|
RoomAgent.agent_id == agent_id
|
||||||
|
).update({"is_active": False})
|
||||||
|
|
||||||
|
if result > 0:
|
||||||
|
# Record system message
|
||||||
|
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||||
|
msg = Message(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
room_id=room_id,
|
||||||
|
sender_id=agent_id,
|
||||||
|
sender_type="system",
|
||||||
|
sender_name="System",
|
||||||
|
role="system",
|
||||||
|
content=json.dumps({"type": "agent_leave", "agent_id": agent_id, "agent_name": agent.name if agent else agent_id})
|
||||||
|
)
|
||||||
|
db.add(msg)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to remove agent from room: {e}")
|
||||||
|
db.rollback()
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
def get_messages(self, room_id: str, limit: int = 50, before_id: str = None) -> List[Dict]:
|
def get_messages(self, room_id: str, limit: int = 50, before_id: str = None) -> List[Dict]:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|
@ -175,29 +284,59 @@ class ChatRoomService:
|
||||||
def save_message(
|
def save_message(
|
||||||
self,
|
self,
|
||||||
room_id: str,
|
room_id: str,
|
||||||
role: str,
|
sender_type: str,
|
||||||
sender_name: str,
|
sender_name: str,
|
||||||
content: str,
|
content: str,
|
||||||
|
sender_id: str = None,
|
||||||
mentions: List[str] = None,
|
mentions: List[str] = None,
|
||||||
token_count: int = 0
|
token_count: int = 0,
|
||||||
|
is_streaming: bool = False,
|
||||||
|
stream_id: str = None,
|
||||||
|
parent_id: str = None
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
|
"""Save a message to the room
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: Room ID
|
||||||
|
sender_type: "user" | "agent" | "system"
|
||||||
|
sender_name: Display name of sender
|
||||||
|
content: Message content (can be plain text or JSON string)
|
||||||
|
sender_id: Sender ID (user_id or agent_id)
|
||||||
|
mentions: List of mentioned agent IDs
|
||||||
|
token_count: Token usage count
|
||||||
|
is_streaming: Whether this is a streaming message
|
||||||
|
stream_id: Streaming session ID
|
||||||
|
parent_id: Parent message ID (for replies)
|
||||||
|
"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
# Resolve sender_id from sender_name if not provided
|
||||||
|
if not sender_id:
|
||||||
|
sender_id = sender_name
|
||||||
|
|
||||||
|
# Wrap plain text content in JSON format
|
||||||
|
if not content.startswith('{'):
|
||||||
|
content = json.dumps({"text": content})
|
||||||
|
|
||||||
msg = Message(
|
msg = Message(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
role=role,
|
sender_id=str(sender_id),
|
||||||
|
sender_type=sender_type,
|
||||||
sender_name=sender_name,
|
sender_name=sender_name,
|
||||||
|
role=sender_type, # Keep role in sync
|
||||||
content=content,
|
content=content,
|
||||||
mentions=json.dumps(mentions) if mentions else None,
|
mentions=json.dumps(mentions) if mentions else None,
|
||||||
token_count=token_count
|
token_count=token_count,
|
||||||
|
is_streaming=is_streaming,
|
||||||
|
stream_id=stream_id,
|
||||||
|
parent_id=parent_id
|
||||||
)
|
)
|
||||||
db.add(msg)
|
db.add(msg)
|
||||||
|
|
||||||
# Update room updated_at
|
# Update room updated_at
|
||||||
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
|
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
|
||||||
if room:
|
if room:
|
||||||
from datetime import datetime
|
|
||||||
room.updated_at = datetime.now()
|
room.updated_at = datetime.now()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -206,8 +345,19 @@ class ChatRoomService:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
async def process_message(
|
async def process_message(
|
||||||
self, room_id: str, user_message: str, sender_id: str, sender_name: str = None
|
self, room_id: str, user_message: str, sender_id: str, sender_name: str = None,
|
||||||
|
context: dict = None, skip_save_user_message: bool = False
|
||||||
):
|
):
|
||||||
|
"""Process a message and trigger agent responses
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: Room ID
|
||||||
|
user_message: The user's message content
|
||||||
|
sender_id: Sender ID (user_id or agent_id)
|
||||||
|
sender_name: Sender display name
|
||||||
|
context: Additional context
|
||||||
|
skip_save_user_message: If True, skip saving user message (already saved by caller)
|
||||||
|
"""
|
||||||
room = self.get_room(room_id)
|
room = self.get_room(room_id)
|
||||||
if not room:
|
if not room:
|
||||||
yield {"event": "error", "data": {"content": "Chat room not found"}}
|
yield {"event": "error", "data": {"content": "Chat room not found"}}
|
||||||
|
|
@ -218,9 +368,10 @@ class ChatRoomService:
|
||||||
yield {"event": "error", "data": {"content": "No agents available"}}
|
yield {"event": "error", "data": {"content": "No agents available"}}
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if sender is agent
|
# Determine sender type
|
||||||
from luxx.agents.registry import agent_registry
|
from luxx.agents.registry import agent_registry
|
||||||
sender_is_agent = agent_registry.get(sender_id) is not None
|
sender_is_agent = agent_registry.get(sender_id) is not None
|
||||||
|
sender_type = "agent" if sender_is_agent else "user"
|
||||||
|
|
||||||
# Filter out sender if agent
|
# Filter out sender if agent
|
||||||
if sender_is_agent:
|
if sender_is_agent:
|
||||||
|
|
@ -243,15 +394,108 @@ class ChatRoomService:
|
||||||
yield {"event": "no_response", "data": {"message": "No agents triggered"}}
|
yield {"event": "no_response", "data": {"message": "No agents triggered"}}
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get history
|
# Save user message (or use existing one if already saved)
|
||||||
|
if skip_save_user_message:
|
||||||
|
# Get the message that was already saved
|
||||||
|
from luxx.core.database import SessionLocal
|
||||||
|
from luxx.models.chat import Message
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
recent_msg = db.query(Message).filter(
|
||||||
|
Message.room_id == room_id
|
||||||
|
).order_by(Message.created_at.desc()).first()
|
||||||
|
user_msg = recent_msg.to_dict() if recent_msg else {"id": None}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
else:
|
||||||
|
user_msg = self.save_message(
|
||||||
|
room_id=room_id,
|
||||||
|
sender_type="user",
|
||||||
|
sender_name=sender_name or "User",
|
||||||
|
content=user_message,
|
||||||
|
sender_id=str(sender_id),
|
||||||
|
mentions=[a.agent_id for a in triggered] if mentions else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get history for context
|
||||||
messages = self.get_messages(room_id, limit=20)
|
messages = self.get_messages(room_id, limit=20)
|
||||||
|
|
||||||
# Stream responses
|
# Stream responses with new event format
|
||||||
for agent in triggered:
|
for agent in triggered:
|
||||||
async for event in agent.stream_response(user_message, messages):
|
stream_id = f"stream_{uuid.uuid4().hex[:8]}"
|
||||||
yield event
|
|
||||||
|
|
||||||
self.save_message(room_id, "user", sender_id, sender_name, user_message)
|
# Emit stream_start
|
||||||
|
yield {
|
||||||
|
"event": "stream_start",
|
||||||
|
"data": {
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"message_id": None, # Will be set when complete
|
||||||
|
"agent": {"id": agent.agent_id, "name": agent.name},
|
||||||
|
"parent_message_id": user_msg["id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
full_content = ""
|
||||||
|
|
||||||
|
# Parse SSE string and transform to new format
|
||||||
|
async for sse_str in agent.stream_response(user_message, messages):
|
||||||
|
# SSE format: "event: xxx\ndata: {...}\n\n"
|
||||||
|
try:
|
||||||
|
event_type = "process_step" # default
|
||||||
|
data_str = ""
|
||||||
|
|
||||||
|
for line in sse_str.strip().split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('event: '):
|
||||||
|
event_type = line[7:].strip()
|
||||||
|
elif line.startswith('data: '):
|
||||||
|
data_str = line[6:].strip()
|
||||||
|
|
||||||
|
if not data_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
import json
|
||||||
|
data = json.loads(data_str)
|
||||||
|
|
||||||
|
if event_type == "process_step":
|
||||||
|
step = data.get("step", {})
|
||||||
|
full_content = step.get("content", full_content)
|
||||||
|
yield {
|
||||||
|
"event": "stream_step",
|
||||||
|
"data": {
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"step": {
|
||||||
|
"id": step.get("id", "step_0"),
|
||||||
|
"type": step.get("type", "text"),
|
||||||
|
"delta": step.get("content", ""),
|
||||||
|
"full": full_content,
|
||||||
|
"done": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elif event_type == "done":
|
||||||
|
yield {
|
||||||
|
"event": "stream_end",
|
||||||
|
"data": {
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"content": full_content,
|
||||||
|
"token_count": data.get("token_count", 0),
|
||||||
|
"usage": data.get("usage", {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elif event_type == "error":
|
||||||
|
yield {
|
||||||
|
"event": "stream_error",
|
||||||
|
"data": {
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"error": data.get("content", "Unknown error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing SSE string: {e}, raw: {sse_str[:100]}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield {"event": "message_sent", "data": {"message": user_msg}}
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""WebSocket handler for Chat Rooms - unified user and agent participants."""
|
"""WebSocket handler for Chat Rooms - unified user and agent participants."""
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Set
|
from typing import Dict, Set
|
||||||
from fastapi import WebSocket, WebSocketDisconnect
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
|
|
@ -9,6 +10,11 @@ from luxx.services.participant import participant_service
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_message(event: str, data: dict) -> dict:
|
||||||
|
"""Create a standardized WebSocket message"""
|
||||||
|
return {"event": event, "data": data}
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._rooms: Dict[str, Set[WebSocket]] = {}
|
self._rooms: Dict[str, Set[WebSocket]] = {}
|
||||||
|
|
@ -18,25 +24,44 @@ class ConnectionManager:
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
self._rooms.setdefault(room_id, set()).add(ws)
|
self._rooms.setdefault(room_id, set()).add(ws)
|
||||||
self._info[ws] = {"type": ptype, "id": pid, "name": pname}
|
self._info[ws] = {"type": ptype, "id": pid, "name": pname}
|
||||||
await ws.send_json({"event": "connected", "data": {"room_id": room_id, "type": ptype}})
|
await ws.send_json(_ws_message("connected", {
|
||||||
|
"room_id": room_id,
|
||||||
|
"participant_type": ptype,
|
||||||
|
"participant_id": pid,
|
||||||
|
"joined_at": None # Will be set by caller
|
||||||
|
}))
|
||||||
|
|
||||||
def disconnect(self, ws: WebSocket):
|
def disconnect(self, ws: WebSocket):
|
||||||
info = self._info.pop(ws, {})
|
info = self._info.pop(ws, {})
|
||||||
room = self._rooms.get(self._info.get(ws, {}).get("id"))
|
for room_id, room_ws in list(self._rooms.items()):
|
||||||
if room:
|
if ws in room_ws:
|
||||||
room.discard(ws)
|
room_ws.discard(ws)
|
||||||
if not room:
|
if not room_ws:
|
||||||
del self._rooms[room]
|
del self._rooms[room_id]
|
||||||
|
break
|
||||||
return info
|
return info
|
||||||
|
|
||||||
async def broadcast(self, room_id: str, msg: dict, exclude: WebSocket = None):
|
async def broadcast(self, room_id: str, msg: dict, exclude: WebSocket = None):
|
||||||
for ws in self._rooms.get(room_id, set()):
|
"""Broadcast message to all clients in a room"""
|
||||||
|
ws_list = list(self._rooms.get(room_id, set()))
|
||||||
|
for ws in ws_list:
|
||||||
if ws != exclude:
|
if ws != exclude:
|
||||||
try:
|
try:
|
||||||
await ws.send_json(msg)
|
await ws.send_json(msg)
|
||||||
except:
|
except:
|
||||||
self.disconnect(ws)
|
self.disconnect(ws)
|
||||||
|
|
||||||
|
async def send_to(self, ws: WebSocket, msg: dict):
|
||||||
|
"""Send message to a specific client"""
|
||||||
|
try:
|
||||||
|
await ws.send_json(msg)
|
||||||
|
except:
|
||||||
|
self.disconnect(ws)
|
||||||
|
|
||||||
|
async def broadcast_to_room(self, room_id: str, msg: dict, exclude: WebSocket = None):
|
||||||
|
"""Alias for broadcast - for compatibility with participant_service"""
|
||||||
|
await self.broadcast(room_id, msg, exclude)
|
||||||
|
|
||||||
def size(self, room_id: str) -> int:
|
def size(self, room_id: str) -> int:
|
||||||
return len(self._rooms.get(room_id, set()))
|
return len(self._rooms.get(room_id, set()))
|
||||||
|
|
||||||
|
|
@ -45,6 +70,7 @@ cm = ConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
async def websocket_handler(ws: WebSocket, room_id: str):
|
async def websocket_handler(ws: WebSocket, room_id: str):
|
||||||
|
"""Main WebSocket handler for chat rooms"""
|
||||||
params = dict(ws.query_params)
|
params = dict(ws.query_params)
|
||||||
ptype = params.get("participant_type", "user")
|
ptype = params.get("participant_type", "user")
|
||||||
pid = params.get("participant_id", "")
|
pid = params.get("participant_id", "")
|
||||||
|
|
@ -54,27 +80,61 @@ async def websocket_handler(ws: WebSocket, room_id: str):
|
||||||
|
|
||||||
room = chat_room_service.get_room(room_id)
|
room = chat_room_service.get_room(room_id)
|
||||||
if not room:
|
if not room:
|
||||||
await ws.send_json({"event": "error", "data": {"content": "Room not found"}})
|
await ws.send_json(_ws_message("error", {"content": "Room not found"}))
|
||||||
await ws.close()
|
await ws.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Register agent if applicable
|
||||||
if ptype == "agent" and pid:
|
if ptype == "agent" and pid:
|
||||||
agent = chat_room_service.get_agent(pid)
|
agent = chat_room_service.get_agent(pid)
|
||||||
if agent:
|
if agent:
|
||||||
participant_service.register_agent(agent)
|
participant_service.register_agent(agent)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Get room agents info (only once)
|
||||||
|
agents = chat_room_service.get_room_agents_info(room_id)
|
||||||
|
|
||||||
|
# Send room info
|
||||||
|
room_dict = room.to_dict()
|
||||||
|
room_dict["agents"] = agents
|
||||||
|
await ws.send_json(_ws_message("room_info", {
|
||||||
|
"room": room_dict
|
||||||
|
}))
|
||||||
|
|
||||||
# Send history
|
# Send history
|
||||||
await ws.send_json({"event": "history", "data": {"messages": chat_room_service.get_messages(room_id)}})
|
messages = chat_room_service.get_messages(room_id)
|
||||||
await ws.send_json({"event": "agents", "data": {
|
await ws.send_json(_ws_message("history", {
|
||||||
"agents": [a.to_dict() for a in chat_room_service.get_room_agents(room_id)]
|
"messages": messages,
|
||||||
}})
|
"has_more": False
|
||||||
|
}))
|
||||||
|
|
||||||
await cm.broadcast(room_id, {
|
# Send agents list (from RoomAgent table - stable source)
|
||||||
"event": "system",
|
await ws.send_json(_ws_message("agents", {
|
||||||
"data": {"content": f"{pname} joined", "type": f"{ptype}_join"}
|
"agents": agents,
|
||||||
}, exclude=ws)
|
"count": len(agents)
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Broadcast join event
|
||||||
|
join_msg = chat_room_service.save_message(
|
||||||
|
room_id=room_id,
|
||||||
|
sender_type="system",
|
||||||
|
sender_name="System",
|
||||||
|
content=json.dumps({
|
||||||
|
"type": "participant_join",
|
||||||
|
"participant_type": ptype,
|
||||||
|
"participant_id": pid,
|
||||||
|
"participant_name": pname
|
||||||
|
}),
|
||||||
|
sender_id=pid
|
||||||
|
)
|
||||||
|
await cm.broadcast(room_id, _ws_message("system", {
|
||||||
|
"type": "participant_join",
|
||||||
|
"sender": {"id": pid, "type": ptype, "name": pname},
|
||||||
|
"content": f"{pname} joined the room",
|
||||||
|
"message": join_msg
|
||||||
|
}), exclude=ws)
|
||||||
|
|
||||||
|
# Main message loop
|
||||||
while True:
|
while True:
|
||||||
data = await ws.receive_json()
|
data = await ws.receive_json()
|
||||||
action = data.get("action")
|
action = data.get("action")
|
||||||
|
|
@ -84,27 +144,94 @@ async def websocket_handler(ws: WebSocket, room_id: str):
|
||||||
if not content:
|
if not content:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sid = pid if ptype == "agent" else str(data.get("user_id", pid or "anonymous"))
|
reply_to = data.get("reply_to") # Optional: reply to a message
|
||||||
sname = pname if ptype == "agent" else data.get("user_name", pname or "Anonymous")
|
mentions = data.get("mentions", []) # Optional: mentioned agents
|
||||||
|
|
||||||
async for event in participant_service.process_message(
|
# Save user message first
|
||||||
room_id, content, sid, sname, ptype
|
user_msg = chat_room_service.save_message(
|
||||||
|
room_id=room_id,
|
||||||
|
sender_type=ptype,
|
||||||
|
sender_name=pname,
|
||||||
|
content=content,
|
||||||
|
sender_id=pid,
|
||||||
|
mentions=mentions,
|
||||||
|
parent_id=reply_to
|
||||||
|
)
|
||||||
|
|
||||||
|
# Broadcast user message
|
||||||
|
await cm.broadcast(room_id, _ws_message("message", {
|
||||||
|
"message": user_msg
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Process and broadcast agent responses
|
||||||
|
sender_id = pid if ptype == "agent" else str(data.get("user_id", pid or "anonymous"))
|
||||||
|
sender_name = pname if ptype == "agent" else data.get("user_name", pname or "Anonymous")
|
||||||
|
|
||||||
|
async for event in chat_room_service.process_message(
|
||||||
|
room_id, content, sender_id, sender_name, skip_save_user_message=True
|
||||||
):
|
):
|
||||||
if event.get("event") in ["process_step", "done", "error"]:
|
# Broadcast stream events to all clients
|
||||||
await cm.broadcast(room_id, {
|
await cm.broadcast(room_id, event)
|
||||||
"event": event["event"],
|
|
||||||
"data": event.get("data", {}),
|
# Also send the final message to message list
|
||||||
"agent_id": event.get("agent_id")
|
if event.get("event") == "stream_end":
|
||||||
})
|
stream_data = event.get("data", {})
|
||||||
|
agent_info = event.get("data", {}).get("agent")
|
||||||
|
|
||||||
|
# Save agent response as final message
|
||||||
|
agent_msg = chat_room_service.save_message(
|
||||||
|
room_id=room_id,
|
||||||
|
sender_type="agent",
|
||||||
|
sender_name=agent_info.get("name", "Agent") if agent_info else "Agent",
|
||||||
|
content=stream_data.get("content", ""),
|
||||||
|
sender_id=agent_info.get("id") if agent_info else None,
|
||||||
|
token_count=stream_data.get("token_count", 0),
|
||||||
|
parent_id=user_msg.get("id")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Broadcast saved message
|
||||||
|
await cm.broadcast(room_id, _ws_message("message", {
|
||||||
|
"message": agent_msg
|
||||||
|
}))
|
||||||
|
|
||||||
|
elif action == "join":
|
||||||
|
# Handle re-join with updated info
|
||||||
|
ptype = data.get("participant_type", ptype)
|
||||||
|
pid = data.get("participant_id", pid)
|
||||||
|
pname = data.get("participant_name", pname)
|
||||||
|
cm._info[ws] = {"type": ptype, "id": pid, "name": pname}
|
||||||
|
await ws.send_json(_ws_message("joined", {
|
||||||
|
"participant_type": ptype,
|
||||||
|
"participant_id": pid,
|
||||||
|
"participant_name": pname
|
||||||
|
}))
|
||||||
|
|
||||||
elif action == "ping":
|
elif action == "ping":
|
||||||
await ws.send_json({"event": "pong", "data": {}})
|
await ws.send_json(_ws_message("pong", {}))
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
await cm.broadcast(room_id, {"event": "system", "data": {"content": f"{pname} left", "type": "leave"}})
|
# Broadcast leave event
|
||||||
|
leave_msg = chat_room_service.save_message(
|
||||||
|
room_id=room_id,
|
||||||
|
sender_type="system",
|
||||||
|
sender_name="System",
|
||||||
|
content=json.dumps({
|
||||||
|
"type": "participant_leave",
|
||||||
|
"participant_type": ptype,
|
||||||
|
"participant_id": pid,
|
||||||
|
"participant_name": pname
|
||||||
|
}),
|
||||||
|
sender_id=pid
|
||||||
|
)
|
||||||
|
await cm.broadcast(room_id, _ws_message("system", {
|
||||||
|
"type": "participant_leave",
|
||||||
|
"sender": {"id": pid, "type": ptype, "name": pname},
|
||||||
|
"content": f"{pname} left the room",
|
||||||
|
"message": leave_msg
|
||||||
|
}))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WebSocket error: {e}")
|
logger.error(f"WebSocket error: {e}")
|
||||||
await cm.broadcast(room_id, {"event": "error", "data": {"content": str(e)}})
|
await cm.broadcast(room_id, _ws_message("error", {"content": str(e)}))
|
||||||
finally:
|
finally:
|
||||||
cm.disconnect(ws)
|
cm.disconnect(ws)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue