fix: 修复前端部分的问题

This commit is contained in:
ViperEkura 2026-04-28 15:20:08 +08:00
parent 6de12aa954
commit 897e55e672
7 changed files with 331 additions and 51 deletions

View File

@ -112,7 +112,16 @@ const allItems = computed(() => {
} else if (step.type === 'tool_result') {
// tool_result tool_call
const toolId = step.id_ref || step.id
const match = items.findLast(it => it.type === 'tool_call' && it.id === toolId)
// Use find() instead of findLast() for better compatibility
// Search in reverse order to find the most recent matching tool_call
let match = null
for (let j = items.length - 1; j >= 0; j--) {
if (items[j].type === 'tool_call' && items[j].id === toolId) {
match = items[j]
break
}
}
if (match) {
let resultContent = step.content || ''
let displayContent = resultContent

View File

@ -123,7 +123,10 @@ class ParallelStreamManager {
break
case 'agent_error':
store.errorAgentStream(roomId, data.agent_id || data.agentId, data.error)
store.errorAgentStream(roomId, data.agent_id || data.agentId, {
message: data.error,
agentName: data.agent_name || data.agentName
})
break
case 'parallel_end':

View File

@ -49,7 +49,7 @@ const roomsLoading = ref(false)
const providers = ref([])
const showCreate = ref(false)
const creating = ref(false)
const newRoom = ref({ title: '', task: '', max_rounds: 5, agent_ids: [] })
const newRoom = ref({ title: '', task: '', max_rounds: 5, agent_ids: [], execution_mode: 'sequential' })
const showAddToRoom = ref(false)
// ============ Selected room state ============
@ -85,6 +85,25 @@ const canEditRoom = computed(() => room.value?.status !== 'running' && !streamin
const executionMode = computed(() => room.value?.execution_mode || 'sequential')
const isParallelMode = computed(() => executionMode.value === 'parallel')
// Parallel mode agent states
const parallelAgents = computed(() => {
const roomData = store.rooms[selectedId.value]
if (!roomData || !roomData.agents) return {}
return roomData.agents
})
const parallelAgentList = computed(() => Object.values(parallelAgents.value))
const parallelStats = computed(() => {
const agents = parallelAgentList.value
return {
total: agents.length,
completed: agents.filter(a => a.status === 'completed').length,
streaming: agents.filter(a => a.status === 'streaming').length,
error: agents.filter(a => a.status === 'error').length
}
})
// Group messages by round number for better visual organization
const groupedMessages = computed(() => {
const groups = []
@ -228,6 +247,7 @@ async function createRoom() {
title: newRoom.value.title,
task: newRoom.value.task,
max_rounds: newRoom.value.max_rounds,
execution_mode: newRoom.value.execution_mode,
agents
})
showCreate.value = false
@ -243,7 +263,7 @@ async function createRoom() {
}
function resetNewRoom() {
newRoom.value = { title: '', task: '', max_rounds: 5, agent_ids: [] }
newRoom.value = { title: '', task: '', max_rounds: 5, agent_ids: [], execution_mode: 'sequential' }
}
function toggleAgentInNewRoom(agentId) {
@ -275,9 +295,15 @@ async function deleteRoom(id) {
async function selectRoom(id) {
if (streaming.value) {
if (abortController) abortController.abort()
if (isParallelMode.value) {
parallelStreamManager.cancelRoom(selectedId.value)
}
streaming.value = false
}
// Clean up both mode states
streamingMessages.value = {}
store.cleanupRoom(selectedId.value)
selectedId.value = id
error.value = ''
editingRoomAgent.value = null
@ -347,9 +373,15 @@ async function startRoom() {
if (e.name !== 'AbortError') error.value = e.message
} finally {
streaming.value = false
// Reload messages after parallel execution completes
if (selectedId.value) {
const res = await chatRoomsAPI.get(selectedId.value)
room.value = res.data
const [roomRes, msgRes] = await Promise.all([
chatRoomsAPI.get(selectedId.value),
chatRoomsAPI.getMessages(selectedId.value)
])
room.value = roomRes.data
messages.value = msgRes.data?.messages || []
store.cleanupRoom(selectedId.value)
await loadRooms()
}
}
@ -457,11 +489,21 @@ function handleSSEEvent(event, data) {
async function stopRoom() {
try {
if (abortController) { abortController.abort(); abortController = null }
// Cancel sequential mode
if (abortController) {
abortController.abort();
abortController = null
}
// Cancel parallel mode
if (isParallelMode.value) {
parallelStreamManager.cancelRoom(selectedId.value)
}
await chatRoomsAPI.stop(selectedId.value)
room.value = { ...room.value, status: 'paused' }
streaming.value = false
streamingMessages.value = {}
// Clean up parallel store
store.cleanupRoom(selectedId.value)
} catch (e) { console.error('Failed to stop room:', e) }
}
@ -704,12 +746,31 @@ onUnmounted(() => {
<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">
<div class="mode-selector">
<select
v-model="room.execution_mode"
@change="updateExecutionMode"
class="mode-select"
:disabled="!canEditRoom"
>
<option value="sequential">📋 Sequential</option>
<option value="parallel"> Parallel</option>
</select>
</div>
<!-- Parallel status indicator -->
<div v-if="isParallelMode && streaming && parallelAgentList.length > 0" class="parallel-status">
<div class="agent-dots">
<span
v-for="agent in parallelAgentList"
:key="agent.id"
class="status-dot-small"
:class="agent.status"
:style="{ background: agent.message?.sender_color || agent.color || '#2563eb' }"
:title="agent.name + ': ' + agent.status"
></span>
</div>
<span class="progress-text">{{ parallelStats.completed }}/{{ parallelStats.total }}</span>
</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>
@ -733,7 +794,39 @@ onUnmounted(() => {
:room-id="selectedId"
mode="parallel"
/>
<!-- Sequential mode or completed messages -->
<!-- Parallel mode history messages (card layout) -->
<template v-else-if="isParallelMode">
<div v-if="messagesLoading" class="loading-messages"><div class="spinner-small"></div><span>加载中...</span></div>
<div v-else-if="messages.length === 0" class="chat-empty"><p>点击开始启动多 Agent 对话</p></div>
<div v-else class="parallel-history">
<div v-for="(roundMsgs, rIdx) in groupedMessages" :key="rIdx" class="parallel-round">
<div class="round-divider">
<span class="round-divider-line"></span>
<span class="round-divider-label"> {{ roundMsgs[0].round_number || rIdx + 1 }} </span>
<span class="round-divider-line"></span>
</div>
<div class="parallel-grid">
<div
v-for="msg in roundMsgs"
:key="msg.id"
class="parallel-card completed"
:style="{ borderColor: msg.sender_color }"
>
<div class="card-header">
<span class="agent-avatar" :style="{ background: msg.sender_color || '#2563eb' }">
{{ (msg.sender_name || '?').charAt(0) }}
</span>
<span class="agent-name" :style="{ color: msg.sender_color }">{{ msg.sender_name }}</span>
</div>
<div class="card-body">
<MessageBubble :message="msg" :deletable="false" :compact="true" />
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Sequential mode -->
<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>
@ -783,7 +876,19 @@ onUnmounted(() => {
<div class="modal-body">
<div class="fg"><label>标题</label><input v-model="newRoom.title" placeholder="项目架构设计讨论" /></div>
<div class="fg"><label>任务描述</label><textarea v-model="newRoom.task" rows="3" placeholder="描述需要 Agent 讨论的问题..."></textarea></div>
<div class="fg"><label>最大轮次</label><input v-model.number="newRoom.max_rounds" type="number" min="1" max="20" /></div>
<div class="fg-row">
<div class="fg" style="flex: 1;">
<label>最大轮次</label>
<input v-model.number="newRoom.max_rounds" type="number" min="1" max="20" />
</div>
<div class="fg" style="flex: 1;">
<label>执行模式</label>
<select v-model="newRoom.execution_mode">
<option value="sequential">📋 串行 - Agent按顺序发言</option>
<option value="parallel"> 并行 - Agent同时发言</option>
</select>
</div>
</div>
<div class="agents-select">
<label class="agents-select-label">选择 Agent</label>
<div v-if="agentPool.length === 0" class="no-agents-hint">请先在左侧 Agent 池中创建 Agent</div>
@ -977,7 +1082,8 @@ textarea.fi { resize: vertical; min-height: 50px; }
.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); }
.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); cursor: pointer; }
.mode-select:disabled { opacity: 0.7; cursor: not-allowed; background: var(--bg-secondary); }
.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; }
@ -999,6 +1105,46 @@ textarea.fi { resize: vertical; min-height: 50px; }
@keyframes spin { to { transform: rotate(360deg); } }
.streaming-hint { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; color: var(--text-secondary); font-size: 0.8rem; }
/* Parallel status indicator */
.parallel-status {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: rgba(59, 130, 246, 0.1);
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.agent-dots {
display: flex;
gap: 4px;
}
.status-dot-small {
width: 12px;
height: 12px;
border-radius: 50%;
transition: all 0.3s ease;
}
.status-dot-small.streaming {
animation: pulse 1.5s ease-in-out infinite;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
}
.status-dot-small.completed {
opacity: 1;
}
.status-dot-small.error {
opacity: 0.6;
}
.status-dot-small.pending {
opacity: 0.4;
}
.progress-text {
font-size: 0.7rem;
font-weight: 600;
color: #3b82f6;
white-space: nowrap;
}
/* Round group styling */
.round-group { display: flex; flex-direction: column; gap: 0.5rem; }
@ -1040,6 +1186,54 @@ textarea.fi { resize: vertical; min-height: 50px; }
white-space: nowrap;
}
/* Parallel history messages */
.parallel-history { padding: 0.5rem 0; }
.parallel-round { margin-bottom: 1rem; }
.parallel-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
padding: 0.5rem 0;
}
.parallel-card {
background: var(--bg-primary);
border: 2px solid var(--border-light);
border-radius: 12px;
overflow: hidden;
transition: all 0.2s ease;
}
.parallel-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.parallel-card .card-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-light);
}
.parallel-card .agent-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 13px;
}
.parallel-card .agent-name {
font-weight: 600;
font-size: 0.85rem;
}
.parallel-card .card-body {
padding: 12px;
max-height: 300px;
overflow-y: auto;
}
/* Streaming message animation */
.streaming-message {
opacity: 0.85;

View File

@ -123,14 +123,35 @@ class AgenticLoop:
ctx.tool_calls_list, tool_context or {}
)
tool_ids = [tc.get("id") for tc in ctx.tool_calls_list]
tool_step_ids = [
s.id for s in ctx.all_steps
if s.type == StepType.TOOL_CALL and s.id_ref in tool_ids
]
# Build mapping from LLM tool_call_id to step id
# Use index-based matching as fallback when id is empty
tool_call_steps = [s for s in ctx.all_steps if s.type == StepType.TOOL_CALL]
logger.debug(f"[EXECUTE_TOOLS] tool_call_steps: {[(s.id, s.name, s.id_ref) for s in tool_call_steps]}")
logger.debug(f"[EXECUTE_TOOLS] tool_calls_list: {[(tc.get('id'), tc.get('function', {}).get('name')) for tc in ctx.tool_calls_list]}")
for i, (tr, tc) in enumerate(zip(tool_results, ctx.tool_calls_list)):
ref_id = tool_step_ids[i] if i < len(tool_step_ids) else f"step-{len(ctx.all_steps) - len(tool_results) + i}"
# Find the corresponding tool_call step
tc_id = tc.get("id", "")
ref_id = None
# First try to match by LLM tool_call_id
if tc_id:
for s in tool_call_steps:
if s.id_ref == tc_id:
ref_id = s.id
logger.debug(f"[EXECUTE_TOOLS] Matched by id: tc_id={tc_id} -> step_id={ref_id}")
break
# Fallback: use index-based matching
if ref_id is None and i < len(tool_call_steps):
ref_id = tool_call_steps[i].id
logger.debug(f"[EXECUTE_TOOLS] Matched by index: i={i} -> step_id={ref_id}")
# Last resort: generate a step id
if ref_id is None:
ref_id = f"step-{len(ctx.all_steps) - len(tool_results) + i}"
logger.debug(f"[EXECUTE_TOOLS] Generated ref_id: {ref_id}")
_, event = StreamRenderer.render_tool_result(ctx, tr, ref_id)
events.append(event)

View File

@ -296,6 +296,7 @@ class ChatRoomOrchestrator:
# Yield parallel start event
yield sse_event("parallel_start", {
"round": round_num,
"max_rounds": self.max_rounds,
"agents": [{"id": a.id, "name": a.name} for a in agents]
})
@ -364,8 +365,11 @@ class ChatRoomOrchestrator:
):
if delta.content:
accumulated_content += delta.content
# Estimate progress based on content length (assume max ~2000 chars)
progress = min(95, int(len(accumulated_content) / 20))
yield {"type": "message_chunk", "id": msg_id, "content": delta.content,
"accumulated": accumulated_content, "agent_id": agent.id}
"accumulated": accumulated_content, "agent_id": agent.id,
"progress": progress}
if delta.is_complete:
break

View File

@ -181,6 +181,14 @@ class StreamState:
"""Finalize the current step and add to all_steps"""
if self.current_step_id is None:
return
# TOOL_CALL and TOOL_RESULT steps are handled manually in render methods
# They have their own data (name, arguments, id_ref) that can't be auto-generated
if self.current_step_type in (StepType.TOOL_CALL, StepType.TOOL_RESULT):
# Just reset the state without adding a step
self.current_step_id = None
self.current_step_idx = None
self.current_step_type = None
return
if self.current_step_type == StepType.TEXT:
content = self.full_content[self._text_offset:]
elif self.current_step_type == StepType.THINKING:
@ -208,6 +216,10 @@ class StreamState:
"type": "function",
"function": {"name": "", "arguments": ""}
})
else:
# Update id if provided (LLM may send id in a later delta)
if tc_delta.get("id"):
self.tool_calls_list[idx]["id"] = tc_delta["id"]
func = tc_delta.get("function", {})
if func.get("name"):
self.tool_calls_list[idx]["function"]["name"] += func["name"]
@ -238,31 +250,49 @@ class StreamRenderer:
@staticmethod
def render_tool_calls(state: StreamState) -> List[str]:
"""Render tool calls as SSE events"""
"""Render tool calls as SSE events
Note: This manually manages step creation for TOOL_CALL type.
The start_step is called to get a unique step id and index,
but the step is manually created and added to avoid duplication.
"""
import logging
logger = logging.getLogger(__name__)
events = []
for tc in state.tool_calls_list:
# Use start_step to auto-finalize previous and create new step
state.start_step(StepType.TOOL_CALL)
# Get step id and index from start_step
step_id = state.start_step(StepType.TOOL_CALL)
step = Step(
id=state.current_step_id,
id=step_id,
index=state.current_step_idx,
type=StepType.TOOL_CALL,
name=tc["function"]["name"],
arguments=tc["function"]["arguments"],
id_ref=tc.get("id", "")
)
# Append again since start_step finalized previous (if any)
# Manually add the step (finalize_step won't add it for TOOL_CALL type)
state.all_steps.append(step)
logger.debug(f"[TOOL_CALL] Created step: id={step.id}, index={step.index}, name={step.name}, id_ref={step.id_ref}")
events.append(sse_event("process_step", {"step": step.to_dict()}))
# Reset current step state since we manually handled it
state.current_step_id = None
state.current_step_idx = None
state.current_step_type = None
return events
@staticmethod
def render_tool_result(state: StreamState, result: Dict, ref_step_id: str) -> tuple:
"""Render a tool result as SSE event"""
import json
"""Render a tool result as SSE event
Note: This does NOT call start_step because tool_result should be linked
to its corresponding tool_call via id_ref, not create a new independent step.
The index is derived from the tool_call step to maintain proper ordering.
"""
import json
import logging
logger = logging.getLogger(__name__)
# Use start_step to auto-finalize previous and create new step
state.start_step(StepType.TOOL_RESULT)
content = result.get("content", "")
success = True
@ -273,9 +303,19 @@ class StreamRenderer:
except (json.JSONDecodeError, TypeError):
pass
# Find the corresponding tool_call step to get its index
tool_call_step = None
for s in state.all_steps:
if s.type == StepType.TOOL_CALL and s.id == ref_step_id:
tool_call_step = s
break
# Use the same index as the tool_call for proper ordering in frontend
step_index = tool_call_step.index if tool_call_step else state.step_index
step = Step(
id=state.current_step_id,
index=state.current_step_idx,
id=f"result-{ref_step_id}", # Unique id based on tool_call step id
index=step_index, # Same index as tool_call for proper ordering
type=StepType.TOOL_RESULT,
name=result.get("name", ""),
content=content,
@ -285,6 +325,8 @@ class StreamRenderer:
state.all_steps.append(step)
state.add_tool_result(result)
logger.debug(f"[TOOL_RESULT] Created step: id={step.id}, index={step.index}, id_ref={step.id_ref}, ref_step_id={ref_step_id}")
return step, sse_event("process_step", {"step": step.to_dict()})
@staticmethod

View File

@ -159,20 +159,26 @@ class ToolExecutor:
tool_calls: List[Dict[str, Any]],
context: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Process tool calls in parallel"""
"""Process tool calls in parallel
IMPORTANT: Results are returned in the SAME ORDER as input tool_calls,
not in completion order. This ensures proper matching between tool_call
and tool_result steps in the frontend.
"""
if len(tool_calls) <= 1:
return self.process_tool_calls(tool_calls, context)
tool_ctx = self._build_tool_context(context)
try:
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import ThreadPoolExecutor
futures = {}
cached_results = []
# Store futures with their original index to maintain order
futures_with_index = {}
results = [None] * len(tool_calls)
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
for call in tool_calls:
for idx, call in enumerate(tool_calls):
call_id = call.get("id", "")
name = call.get("function", {}).get("name", "")
args = self._parse_arguments(call)
@ -183,24 +189,25 @@ class ToolExecutor:
if cached is not None:
self.history.record(name, args, cached)
cached_results.append(self._create_tool_result(call_id, name, cached))
# Store result at the correct index
results[idx] = self._create_tool_result(call_id, name, cached)
else:
# Submit task
# Submit task with index
future = executor.submit(
registry.execute, name, args, context=tool_ctx
)
futures[future] = (call_id, name, args, cache_key)
futures_with_index[future] = (idx, call_id, name, args, cache_key)
results = list(cached_results)
for future in as_completed(futures):
call_id, name, args, cache_key = futures[future]
# Wait for all futures and store results at correct indices
for future in futures_with_index:
idx, call_id, name, args, cache_key = futures_with_index[future]
result = future.result()
self.cache.set(cache_key, result)
self.history.record(name, args, result)
results.append(self._create_tool_result(call_id, name, result))
results[idx] = self._create_tool_result(call_id, name, result)
return results
# Filter out None values (shouldn't happen, but safety check)
return [r for r in results if r is not None]
except ImportError:
return self.process_tool_calls(tool_calls, context)