feat(agent): add explicit active-turn steering

This commit is contained in:
AaronZ345
2026-07-19 16:03:07 +08:00
parent 0c76d851cb
commit c4e5d11da9
21 changed files with 753 additions and 5 deletions

View File

@@ -485,6 +485,20 @@
<i class="fas fa-microphone text-sm"></i>
</button>
</div>
<button id="steer-btn"
class="hidden flex-shrink-0 w-10 h-10 items-center justify-center rounded-lg
border border-primary-300 dark:border-primary-700
text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
disabled:text-slate-300 dark:disabled:text-slate-600
disabled:border-slate-200 dark:disabled:border-slate-700
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
type="button"
data-i18n-title="steer_active"
data-i18n-aria-label="steer_active"
aria-label="引导当前任务"
title="引导当前任务">
<i class="fas fa-arrow-turn-up text-sm"></i>
</button>
<button id="send-btn"
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
bg-primary-400 text-white hover:bg-primary-500

View File

@@ -123,6 +123,8 @@ const I18N = {
slash_knowledge_off: '关闭知识库',
slash_config: '查看当前配置',
slash_cancel: '中止当前正在运行的 Agent 任务',
slash_steer: '向当前正在运行的 Agent 任务注入引导指令',
steer_active: '引导当前任务',
slash_logs: '查看最近日志',
slash_version: '查看版本',
input_placeholder: '输入消息,或输入 / 使用指令',
@@ -375,6 +377,8 @@ const I18N = {
slash_knowledge_off: '關閉知識庫',
slash_config: '檢視當前設定',
slash_cancel: '中止當前正在執行的 Agent 任務',
slash_steer: '向當前正在執行的 Agent 任務注入引導指令',
steer_active: '引導當前任務',
slash_logs: '檢視最近日誌',
slash_version: '檢視版本',
input_placeholder: '輸入訊息,或輸入 / 使用指令',
@@ -626,6 +630,8 @@ const I18N = {
slash_knowledge_off: 'Disable knowledge base',
slash_config: 'Show current config',
slash_cancel: 'Abort the running Agent task',
slash_steer: 'Inject guidance into the running Agent task',
steer_active: 'Steer active task',
slash_logs: 'Show recent logs',
slash_version: 'Show version',
input_placeholder: 'Type a message, or press / for commands',
@@ -816,6 +822,9 @@ function applyI18n() {
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset['i18nTitle']);
});
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
el.setAttribute('aria-label', t(el.dataset['i18nAriaLabel']));
});
document.querySelectorAll('[data-tip-key]').forEach(el => {
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
});
@@ -1415,6 +1424,7 @@ startPolling();
const chatInput = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
const steerBtn = document.getElementById('steer-btn');
const messagesDiv = document.getElementById('chat-messages');
const fileInput = document.getElementById('file-input');
const folderInput = document.getElementById('folder-input');
@@ -1749,6 +1759,7 @@ function setSendBtnCancelMode(requestId) {
sendBtn.classList.add('send-btn-cancel');
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
updateSteerBtnState();
}
function resetSendBtnSendMode() {
@@ -1757,9 +1768,61 @@ function resetSendBtnSendMode() {
sendBtn.classList.remove('send-btn-cancel');
sendBtn.title = '';
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
steerBtn.classList.add('hidden');
steerBtn.classList.remove('flex');
steerBtn.disabled = true;
updateSendBtnState();
}
function updateSteerBtnState() {
const active = sendBtnMode === 'cancel' && !!activeRequestId;
steerBtn.classList.toggle('hidden', !active);
steerBtn.classList.toggle('flex', active);
steerBtn.disabled = !active || uploadingCount > 0 || !chatInput.value.trim();
}
function steerActiveTask() {
const instruction = chatInput.value.trim();
if (!instruction || sendBtnMode !== 'cancel' || !activeRequestId) return;
inputHistory.push(instruction);
historyIdx = -1;
historySavedDraft = '';
addUserMessage(`${instruction}`, new Date());
chatInput.value = '';
chatInput.style.height = '42px';
chatInput.style.overflowY = 'hidden';
updateSteerBtnState();
fetch('/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
message: instruction,
steer: true,
stream: false,
lang: currentLang,
}),
})
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.inline_reply) {
addBotMessage(data.inline_reply, new Date());
} else {
addBotMessage(t('error_send'), new Date());
}
})
.catch(err => {
console.warn('[steer] request failed', err);
addBotMessage(t('error_send'), new Date());
})
.finally(updateSteerBtnState);
}
steerBtn.addEventListener('click', steerActiveTask);
function requestCancel() {
const reqId = activeRequestId;
if (!reqId) return;
@@ -1795,10 +1858,12 @@ function updateSendBtnState() {
resetSendBtnSendMode();
} else {
// Don't downgrade a genuinely active Cancel button on input edits.
updateSteerBtnState();
return;
}
}
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
updateSteerBtnState();
}
function renderAttachmentPreview() {
@@ -2117,6 +2182,7 @@ const SLASH_COMMANDS = [
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' },
{ cmd: '/config', desc: 'slash_config' },
{ cmd: '/cancel', desc: 'slash_cancel' },
{ cmd: '/steer ', desc: 'slash_steer' },
{ cmd: '/logs', desc: 'slash_logs' },
{ cmd: '/version', desc: 'slash_version' },
];

View File

@@ -6,6 +6,7 @@ import logging
import mimetypes
import os
import random
import re
import shutil
import threading
import time
@@ -134,6 +135,36 @@ def _cancel_reply_text(cancelled: int, lang: str) -> str:
return "Nothing to cancel." if en else "当前没有可中止的任务。"
def _steer_reply_text(status, lang: str) -> str:
from agent.protocol import SteerStatus
en = (lang or "").lower().startswith("en")
messages = {
SteerStatus.ACCEPTED: (
"↪️ Active task redirected.", "↪️ 已引导当前任务。"
),
SteerStatus.INACTIVE: (
"No active task to steer.", "当前没有可引导的任务。"
),
SteerStatus.CLOSING: (
"The active task is already finishing.", "当前任务已结束,无法再引导。"
),
SteerStatus.AMBIGUOUS: (
"Multiple tasks are active in this session; the steering target is ambiguous.",
"当前会话有多个任务在运行,无法确定引导目标。",
),
SteerStatus.FULL: (
"Too many steering updates are pending; try again after the agent processes them.",
"引导指令过多,请等待当前任务处理后再试。",
),
SteerStatus.INVALID: (
"Usage: /steer <instruction>", "用法:/steer <引导指令>"
),
}
english, chinese = messages[status]
return english if en else chinese
def _get_upload_dir() -> str:
from common.utils import expand_path
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
@@ -912,6 +943,37 @@ class WebChannel(ChatChannel):
"inline_reply": msg_text,
})
# Explicit steering also bypasses the normal session queue. The
# Web button sends ``steer: true`` with raw input; typed /steer
# commands use the same endpoint and semantics as IM channels.
steer_requested = bool(json_data.get("steer", False))
is_steer_command = (
re.match(r"^/steer(?:\s|$)", stripped_prompt) is not None
)
if steer_requested or is_steer_command:
instruction = (
(prompt or "").strip()[len("/steer"):].strip()
if is_steer_command
else (prompt or "").strip()
)
from bridge.bridge import Bridge
result = Bridge().get_agent_bridge().steer_session(
session_id, instruction
)
lang = (json_data.get("lang") or "zh").lower()
msg_text = _steer_reply_text(result.status, lang)
logger.info(
f"[WebChannel] steer fast-path: session={session_id}, "
f"status={result.status.value}, lang={lang}"
)
return json.dumps({
"status": "success",
"request_id": "",
"stream": False,
"steered": result.accepted,
"inline_reply": msg_text,
}, ensure_ascii=False)
# Append file references to the prompt (same format as QQ channel)
if attachments:
file_refs = []