feat: add sub agent termination control
This commit is contained in:
parent
3100643534
commit
f2bfa3f377
@ -2010,6 +2010,8 @@ class MainTerminal:
|
||||
task_id=arguments.get("task_id"),
|
||||
agent_id=arguments.get("agent_id")
|
||||
)
|
||||
message = result.get("message") or result.get("error")
|
||||
self._record_sub_agent_message(message, result.get("task_id"), inline=False)
|
||||
|
||||
else:
|
||||
result = {"success": False, "error": f"未知工具: {tool_name}"}
|
||||
|
||||
@ -58,6 +58,6 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
|
||||
),
|
||||
"sub_agent": ToolCategory(
|
||||
label="子智能体",
|
||||
tools=["create_sub_agent", "wait_sub_agent"],
|
||||
tools=["create_sub_agent", "wait_sub_agent", "close_sub_agent"],
|
||||
),
|
||||
}
|
||||
|
||||
@ -219,6 +219,7 @@ class SubAgentManager:
|
||||
|
||||
task_id = task["task_id"]
|
||||
response = self._call_service("POST", f"/tasks/{task_id}/terminate", timeout=10)
|
||||
response["task_id"] = task_id
|
||||
if response.get("success"):
|
||||
task["status"] = "terminated"
|
||||
task["final_result"] = {
|
||||
@ -229,6 +230,8 @@ class SubAgentManager:
|
||||
"message": response.get("message") or "子智能体已被强制关闭。",
|
||||
}
|
||||
self._save_state()
|
||||
if "system_message" not in response:
|
||||
response["system_message"] = response.get("message")
|
||||
return response
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@ -175,6 +175,7 @@ async function bootstrapApp() {
|
||||
|
||||
// 停止功能状态
|
||||
stopRequested: false,
|
||||
terminating: false,
|
||||
|
||||
// 路由相关
|
||||
initialRouteResolved: false,
|
||||
@ -633,6 +634,37 @@ async function bootstrapApp() {
|
||||
}
|
||||
},
|
||||
|
||||
async terminateSubAgentTask() {
|
||||
if (!this.subAgentTaskId || this.terminating) {
|
||||
return;
|
||||
}
|
||||
const shouldTerminate = window.confirm('确定要立即关闭子智能体吗?此操作无法撤销。');
|
||||
if (!shouldTerminate) {
|
||||
return;
|
||||
}
|
||||
this.terminating = true;
|
||||
try {
|
||||
const resp = await fetch(`/tasks/${encodeURIComponent(this.subAgentTaskId)}/terminate`, { method: 'POST' });
|
||||
let data = null;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (err) {
|
||||
data = { success: false, error: await resp.text() };
|
||||
}
|
||||
if (!resp.ok || !data.success) {
|
||||
const message = (data && (data.error || data.message)) || '关闭失败';
|
||||
alert(`关闭子智能体失败:${message}`);
|
||||
return;
|
||||
}
|
||||
this.addSystemMessage('🛑 子智能体已被手动关闭。');
|
||||
} catch (error) {
|
||||
console.warn('关闭子智能体失败:', error);
|
||||
alert(`关闭子智能体失败:${error.message || error}`);
|
||||
} finally {
|
||||
this.terminating = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatPathDisplay(path) {
|
||||
if (!path) {
|
||||
return '—';
|
||||
@ -647,7 +679,8 @@ async function bootstrapApp() {
|
||||
pending: '等待中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
timeout: '超时'
|
||||
timeout: '超时',
|
||||
terminated: '已关闭'
|
||||
};
|
||||
return map[status] || status;
|
||||
},
|
||||
|
||||
@ -59,6 +59,13 @@
|
||||
<span class="thinking-mode" v-else>
|
||||
状态: {{ formatSubAgentStatus(subAgentTaskInfo.status) }}
|
||||
</span>
|
||||
<button v-if="isSubAgentView"
|
||||
type="button"
|
||||
class="btn stop-btn terminate-btn"
|
||||
@click="terminateSubAgentTask"
|
||||
:disabled="terminating">
|
||||
{{ terminating ? '关闭中...' : '关闭子智能体' }}
|
||||
</button>
|
||||
<span class="connection-status" :class="{ connected: isConnected }">
|
||||
<span class="status-dot" :class="{ active: isConnected }"></span>
|
||||
{{ isConnected ? '已连接' : '未连接' }}
|
||||
@ -228,39 +235,6 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Token区域包装器 -->
|
||||
<div class="token-wrapper" v-if="currentConversationId">
|
||||
<!-- Token统计显示面板 -->
|
||||
<div class="token-display-panel" :class="{ collapsed: tokenPanelCollapsed }">
|
||||
<div class="token-panel-content">
|
||||
<div class="token-stats">
|
||||
<div class="token-item">
|
||||
<span class="token-label">当前上下文</span>
|
||||
<span class="token-value current">{{ formatTokenCount(currentContextTokens || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="token-separator"></div>
|
||||
|
||||
<div class="token-item">
|
||||
<span class="token-label">累计输入</span>
|
||||
<span class="token-value input">{{ formatTokenCount(currentConversationTokens.cumulative_input_tokens || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="token-item">
|
||||
<span class="token-label">累计输出</span>
|
||||
<span class="token-value output">{{ formatTokenCount(currentConversationTokens.cumulative_output_tokens || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 独立的切换按钮 -->
|
||||
<button @click="toggleTokenPanel" class="token-toggle-btn" :class="{ collapsed: tokenPanelCollapsed }">
|
||||
<span v-if="!tokenPanelCollapsed">▲</span>
|
||||
<span v-else>▼</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="messages-area" ref="messagesArea">
|
||||
<div v-for="(msg, index) in messages" :key="index" class="message-block">
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
--claude-shadow: 0 14px 36px rgba(61, 57, 41, 0.12);
|
||||
--claude-success: #76b086;
|
||||
--claude-warning: #d99845;
|
||||
--claude-danger: #d65a5a;
|
||||
}
|
||||
|
||||
html, body {
|
||||
@ -86,6 +87,21 @@ body {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.terminate-btn {
|
||||
background: var(--claude-danger);
|
||||
color: #fff6f6;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terminate-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
font-size: 13px;
|
||||
color: var(--claude-text-secondary);
|
||||
@ -1373,11 +1389,6 @@ body {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 输入区域 */
|
||||
.token-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border-top: 1px solid var(--claude-border);
|
||||
@ -1530,9 +1541,6 @@ body {
|
||||
.messages-area {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.token-wrapper {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.input-area {
|
||||
padding: 14px;
|
||||
}
|
||||
@ -1862,230 +1870,7 @@ body {
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.token-count {
|
||||
color: var(--claude-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* ========================================= */
|
||||
/* Token 统计面板样式(无缝一体版)*/
|
||||
/* ========================================= */
|
||||
|
||||
/* Token区域包装器 */
|
||||
.token-wrapper {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 当前对话信息栏 - 移除底部边框 */
|
||||
.current-conversation-info {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: var(--claude-panel);
|
||||
backdrop-filter: blur(18px);
|
||||
border-bottom: none; /* 移除边框,让它和下面的面板融为一体 */
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--claude-text-secondary);
|
||||
border-radius: 0; /* 顶部保持直角 */
|
||||
box-shadow: 0 12px 28px rgba(61, 57, 41, 0.06);
|
||||
}
|
||||
|
||||
/* Token面板 - 与标题栏完全一体,底部圆角 */
|
||||
.token-display-panel {
|
||||
background: var(--claude-panel);
|
||||
backdrop-filter: blur(18px);
|
||||
border: none;
|
||||
border-radius: 0 0 16px 16px;
|
||||
box-shadow: 0 8px 18px rgba(189, 93, 58, 0.12);
|
||||
overflow: hidden;
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 展开状态 */
|
||||
.token-display-panel:not(.collapsed) {
|
||||
height: 80px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 收起状态 */
|
||||
.token-display-panel.collapsed {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.token-panel-content {
|
||||
padding: 16px 24px;
|
||||
height: 100%;
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.token-display-panel.collapsed .token-panel-content {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.token-stats {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.token-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.token-label {
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
color: var(--claude-text);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.token-value.current {
|
||||
color: var(--claude-accent);
|
||||
font-size: 20px;
|
||||
}
|
||||
.token-value.input { color: var(--claude-success); }
|
||||
.token-value.output { color: var(--claude-warning); }
|
||||
|
||||
.token-separator {
|
||||
width: 1px;
|
||||
height: 35px;
|
||||
background: linear-gradient(to bottom,
|
||||
transparent,
|
||||
rgba(218, 119, 86, 0.25) 20%,
|
||||
rgba(218, 119, 86, 0.25) 80%,
|
||||
transparent
|
||||
);
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* 切换按钮 - 独立定位 */
|
||||
.token-toggle-btn {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: -18px; /* 相对于wrapper底部 */
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(218, 119, 86, 0.3);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 15;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 展开状态 */
|
||||
.token-toggle-btn:not(.collapsed) {
|
||||
background: linear-gradient(135deg, #ffffff 0%, rgba(255, 248, 242, 0.9) 100%);
|
||||
color: var(--claude-accent);
|
||||
box-shadow: 0 3px 10px rgba(189, 93, 58, 0.18);
|
||||
}
|
||||
|
||||
/* 收起状态 - 在标题栏下方露出一半 */
|
||||
.token-toggle-btn.collapsed {
|
||||
background: linear-gradient(135deg, var(--claude-accent) 0%, var(--claude-accent-strong) 100%);
|
||||
color: #fff8f2;
|
||||
border-color: rgba(255, 248, 242, 0.55);
|
||||
box-shadow: 0 3px 11px rgba(189, 93, 58, 0.22);
|
||||
}
|
||||
|
||||
.token-toggle-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 5px 16px rgba(189, 93, 58, 0.26);
|
||||
}
|
||||
|
||||
.token-toggle-btn.collapsed:hover {
|
||||
background: linear-gradient(135deg, var(--claude-button-hover) 0%, var(--claude-button-active) 100%);
|
||||
}
|
||||
|
||||
.token-toggle-btn:active {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 箭头样式 - 移除浮动动画 */
|
||||
.token-toggle-btn span {
|
||||
transition: all 0.3s ease;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 移除动画效果 */
|
||||
/* .token-toggle-btn:not(.collapsed) span {
|
||||
animation: arrowBounceUp 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.token-toggle-btn.collapsed span {
|
||||
animation: arrowBounceDown 2s ease-in-out infinite;
|
||||
} */
|
||||
|
||||
/* 保留动画定义,但不使用 */
|
||||
@keyframes arrowBounceUp {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
@keyframes arrowBounceDown {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(3px); }
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.token-stats {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.token-item {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.token-value.current {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.token-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.token-toggle-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
/* Markdown列表样式 - 修复偏左问题 */
|
||||
.text-content ul,
|
||||
.text-content ol {
|
||||
|
||||
@ -74,6 +74,7 @@ user_terminals: Dict[str, WebTerminal] = {}
|
||||
terminal_rooms: Dict[str, set] = {}
|
||||
connection_users: Dict[str, str] = {}
|
||||
stop_flags: Dict[str, Dict[str, Any]] = {}
|
||||
terminated_tasks: set = set()
|
||||
|
||||
DEFAULT_PORT = 8092
|
||||
|
||||
@ -269,6 +270,7 @@ def _purge_sub_agent_task(task_id: str):
|
||||
terminal.close()
|
||||
except Exception:
|
||||
pass
|
||||
terminated_tasks.discard(task_id)
|
||||
room_sids = sub_agent_rooms.pop(task_id, set())
|
||||
for sid in list(room_sids):
|
||||
sub_agent_connections.pop(sid, None)
|
||||
@ -2065,6 +2067,7 @@ def create_sub_agent_task(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
metadata=metadata,
|
||||
message_callback=None,
|
||||
)
|
||||
terminal.task_id = task_id
|
||||
|
||||
def _finish_hook(result: Dict[str, Any]):
|
||||
reason = result.get("reason", "")
|
||||
@ -2152,10 +2155,10 @@ def force_terminate_sub_agent(task_id: str) -> Dict[str, Any]:
|
||||
update_sub_agent_task(task_id, status="terminated")
|
||||
broadcast_sub_agent_event(task_id, "sub_agent_status", {
|
||||
"status": "terminated",
|
||||
"message": "子智能体已强制关闭"
|
||||
"message": "子智能体已被手动关闭"
|
||||
})
|
||||
_purge_sub_agent_task(task_id)
|
||||
return {"success": True, "message": "子智能体已强制关闭"}
|
||||
return {"success": True, "message": "子智能体已被手动关闭"}
|
||||
|
||||
|
||||
# ==========================================
|
||||
@ -2438,6 +2441,7 @@ def process_message_task(terminal: WebTerminal, message: str, sender, client_sid
|
||||
stop_flags[client_sid]['task'] = task
|
||||
stop_flags[client_sid]['terminal'] = terminal
|
||||
|
||||
task_id = getattr(terminal, "task_id", None)
|
||||
try:
|
||||
loop.run_until_complete(task)
|
||||
except asyncio.CancelledError:
|
||||
@ -2447,6 +2451,9 @@ def process_message_task(terminal: WebTerminal, message: str, sender, client_sid
|
||||
'reason': 'user_requested'
|
||||
})
|
||||
reset_system_state(terminal)
|
||||
finally:
|
||||
if task_id:
|
||||
terminated_tasks.discard(task_id)
|
||||
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
@ -3145,6 +3152,10 @@ async def handle_task_with_sender(terminal: WebTerminal, message, sender, client
|
||||
for iteration in range(max_iterations):
|
||||
total_iterations += 1
|
||||
debug_log(f"\n--- 迭代 {iteration + 1}/{max_iterations} 开始 ---")
|
||||
task_id = getattr(web_terminal, "task_id", None)
|
||||
if task_id and task_id in terminated_tasks:
|
||||
sender('system_message', {'content': '🛑 子智能体已被手动关闭。'})
|
||||
break
|
||||
|
||||
# 检查是否超过总工具调用限制
|
||||
if total_tool_calls >= MAX_TOTAL_TOOL_CALLS:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user