mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-06-02 00:57:41 +08:00
feat(agent): support user-initiated cancel for in-flight agent runs
This commit is contained in:
@@ -438,8 +438,21 @@ class ChatChannel(Channel):
|
||||
|
||||
return func
|
||||
|
||||
# Chat commands that must bypass the per-session serial queue,
|
||||
# otherwise /cancel would queue behind the task it tries to cancel.
|
||||
# Use /cancel (not /stop) to avoid colliding with `cow stop` CLI.
|
||||
_BYPASS_QUEUE_COMMANDS = ("/cancel",)
|
||||
|
||||
def produce(self, context: Context):
|
||||
session_id = context["session_id"]
|
||||
|
||||
# Fast path: /cancel must not enter the queue.
|
||||
if context.type == ContextType.TEXT and context.content:
|
||||
stripped = context.content.strip().lower()
|
||||
if stripped in self._BYPASS_QUEUE_COMMANDS:
|
||||
self._handle_cancel_command(context, session_id)
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
if session_id not in self.sessions:
|
||||
self.sessions[session_id] = [
|
||||
@@ -451,6 +464,29 @@ class ChatChannel(Channel):
|
||||
else:
|
||||
self.sessions[session_id][0].put(context)
|
||||
|
||||
def _handle_cancel_command(self, context: Context, session_id: str) -> None:
|
||||
"""Cancel any in-flight agent run for *session_id* and reply inline.
|
||||
|
||||
Runs synchronously on the caller's thread. Reply is sent through
|
||||
_send_reply so plugins (e.g. logging) still observe it.
|
||||
"""
|
||||
try:
|
||||
from agent.protocol import get_cancel_registry
|
||||
from bridge.reply import Reply, ReplyType
|
||||
|
||||
cancelled = get_cancel_registry().cancel_session(session_id)
|
||||
text = (
|
||||
"🛑 已中止"
|
||||
if cancelled > 0
|
||||
else "当前没有可中止的任务。"
|
||||
)
|
||||
logger.info(
|
||||
f"[chat_channel] /cancel fast-path: session={session_id}, cancelled={cancelled}"
|
||||
)
|
||||
self._send_reply(context, Reply(ReplyType.TEXT, text))
|
||||
except Exception as e:
|
||||
logger.warning(f"[chat_channel] /cancel fast-path failed: {e}")
|
||||
|
||||
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
|
||||
def consume(self):
|
||||
while True:
|
||||
|
||||
@@ -752,6 +752,9 @@ class FeiShuChanel(ChatChannel):
|
||||
init_in_flight = [False]
|
||||
# 一旦初始化失败就长期标记为 disabled,本次回复不再尝试任何流式调用
|
||||
disabled = [False]
|
||||
# True after agent_cancelled: agent_end stops rewriting the card
|
||||
# with stale final_response and just finalizes current content.
|
||||
cancelled = [False]
|
||||
lock = threading.Lock()
|
||||
|
||||
# ---- 异步推送队列 ----------------------------------------------------
|
||||
@@ -1076,18 +1079,42 @@ class FeiShuChanel(ChatChannel):
|
||||
message_id[0] = None
|
||||
sequence[0] = 0
|
||||
|
||||
elif event_type == "agent_cancelled":
|
||||
# Lock channel into "no-rewrite" mode: the subsequent
|
||||
# agent_end's final_response is from the last *completed*
|
||||
# turn (the user already saw it), so rewriting the card
|
||||
# would duplicate it visually.
|
||||
with lock:
|
||||
cancelled[0] = True
|
||||
|
||||
elif event_type == "agent_end":
|
||||
# 最终回复:用 final_response 覆盖当前流式卡片,然后关闭流式模式。
|
||||
final_response = data.get("final_response", "")
|
||||
if not final_response:
|
||||
return
|
||||
final_text = str(final_response)
|
||||
# 标记 streamed 让 chat_channel 跳过 send()
|
||||
context["feishu_streamed"] = True
|
||||
|
||||
with lock:
|
||||
was_cancelled = cancelled[0]
|
||||
has_card = card_id[0] is not None
|
||||
init_busy = init_in_flight[0]
|
||||
pending_text = current_text[0]
|
||||
|
||||
if was_cancelled:
|
||||
# Cancelled path: finalize the in-flight card with
|
||||
# partial output (or a short marker if empty); drop
|
||||
# stale final_response to avoid duplicating last turn.
|
||||
if has_card:
|
||||
_drain_push_queue()
|
||||
partial = (pending_text or "").rstrip()
|
||||
final_text = partial or "_(已中止)_"
|
||||
_stream_update_text(final_text)
|
||||
_close_streaming_mode(final_text)
|
||||
push_queue.put(None)
|
||||
return
|
||||
|
||||
if not final_response:
|
||||
return
|
||||
final_text = str(final_response)
|
||||
|
||||
# 罕见情况:agent_end 触发时还没创建过卡片(极快返回 / 没有
|
||||
# message_update),主动创建一张承载 final_text。
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
bg-primary-400 text-white hover:bg-primary-500
|
||||
disabled:bg-slate-300 dark:disabled:bg-slate-600
|
||||
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
|
||||
disabled onclick="sendMessage()">
|
||||
disabled>
|
||||
<i class="fas fa-paper-plane text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1367,3 +1367,35 @@
|
||||
text-align: right;
|
||||
}
|
||||
.voice-pill audio { display: none; }
|
||||
|
||||
/* Send button toggles into a Stop button while an SSE stream is in flight.
|
||||
Match the look of the disabled send button (light grey block + white
|
||||
glyph) so it reads as the same visual element, just paused/idle from
|
||||
sending perspective and clickable to stop. */
|
||||
#send-btn.send-btn-cancel {
|
||||
background-color: rgb(203 213 225) !important; /* slate-300, == disabled send-btn */
|
||||
color: white !important;
|
||||
}
|
||||
#send-btn.send-btn-cancel:hover {
|
||||
background-color: rgb(148 163 184) !important; /* slate-400 */
|
||||
}
|
||||
#send-btn.send-btn-cancel:disabled {
|
||||
background-color: rgb(226 232 240) !important; /* slate-200, while stop is in flight */
|
||||
color: white !important;
|
||||
cursor: progress;
|
||||
}
|
||||
.dark #send-btn.send-btn-cancel {
|
||||
background-color: rgb(71 85 105) !important; /* slate-600, == dark disabled send-btn */
|
||||
color: white !important;
|
||||
}
|
||||
.dark #send-btn.send-btn-cancel:hover {
|
||||
background-color: rgb(100 116 139) !important; /* slate-500 */
|
||||
}
|
||||
.dark #send-btn.send-btn-cancel:disabled {
|
||||
background-color: rgb(51 65 85) !important; /* slate-700 */
|
||||
color: rgb(203 213 225) !important;
|
||||
}
|
||||
|
||||
.agent-cancelled-tag {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -1016,7 +1016,60 @@ const inputHistory = [];
|
||||
let historyIdx = -1;
|
||||
let historySavedDraft = '';
|
||||
|
||||
// While an SSE stream is in flight, the send button morphs into a cancel
|
||||
// button. Only one in-flight request is supported at a time.
|
||||
let activeRequestId = null;
|
||||
let sendBtnMode = 'send'; // 'send' | 'cancel'
|
||||
|
||||
function setSendBtnCancelMode(requestId) {
|
||||
activeRequestId = requestId;
|
||||
sendBtnMode = 'cancel';
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.classList.add('send-btn-cancel');
|
||||
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
|
||||
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
|
||||
}
|
||||
|
||||
function resetSendBtnSendMode() {
|
||||
activeRequestId = null;
|
||||
sendBtnMode = 'send';
|
||||
sendBtn.classList.remove('send-btn-cancel');
|
||||
sendBtn.title = '';
|
||||
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
|
||||
updateSendBtnState();
|
||||
}
|
||||
|
||||
function requestCancel() {
|
||||
const reqId = activeRequestId;
|
||||
if (!reqId) return;
|
||||
fetch('/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ request_id: reqId, session_id: sessionId, lang: currentLang }),
|
||||
}).catch(err => {
|
||||
console.warn('[cancel] request failed', err);
|
||||
});
|
||||
// Optimistic UI lock so the click visibly registers before the SSE
|
||||
// "cancelled" event arrives.
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.title = (currentLang === 'zh' ? '已中止' : 'Cancelled');
|
||||
}
|
||||
|
||||
// Button click is the only path to Cancel. Pressing Enter still calls
|
||||
// sendMessage() so users can submit "/cancel" as a regular slash command.
|
||||
sendBtn.addEventListener('click', () => {
|
||||
if (sendBtnMode === 'cancel') {
|
||||
requestCancel();
|
||||
} else {
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
function updateSendBtnState() {
|
||||
if (sendBtnMode === 'cancel') {
|
||||
// Don't downgrade a Cancel button on input edits.
|
||||
return;
|
||||
}
|
||||
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
|
||||
}
|
||||
|
||||
@@ -1264,6 +1317,7 @@ const SLASH_COMMANDS = [
|
||||
{ cmd: '/knowledge on', desc: '开启知识库' },
|
||||
{ cmd: '/knowledge off', desc: '关闭知识库' },
|
||||
{ cmd: '/config', desc: '查看当前配置' },
|
||||
{ cmd: '/cancel', desc: '中止当前正在运行的 Agent 任务' },
|
||||
{ cmd: '/logs', desc: '查看最近日志' },
|
||||
{ cmd: '/version', desc: '查看版本' },
|
||||
];
|
||||
@@ -1534,6 +1588,7 @@ function sendVoiceMessage(text, audioUrl) {
|
||||
stream: true,
|
||||
timestamp: timestamp.toISOString(),
|
||||
is_voice: true,
|
||||
lang: currentLang,
|
||||
};
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
@@ -1547,7 +1602,12 @@ function sendVoiceMessage(text, audioUrl) {
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
if (data.stream) {
|
||||
if (data.inline_reply) {
|
||||
// Synchronous fast-path reply (e.g. /cancel); skip SSE.
|
||||
loadingEl.remove();
|
||||
addBotMessage(data.inline_reply, new Date());
|
||||
} else if (data.stream) {
|
||||
setSendBtnCancelMode(data.request_id);
|
||||
startSSE(data.request_id, loadingEl, timestamp, titleInfo);
|
||||
} else {
|
||||
loadingContainers[data.request_id] = loadingEl;
|
||||
@@ -1555,6 +1615,7 @@ function sendVoiceMessage(text, audioUrl) {
|
||||
} else {
|
||||
loadingEl.remove();
|
||||
addBotMessage(t('error_send'), new Date());
|
||||
resetSendBtnSendMode();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -1591,6 +1652,10 @@ function addUserVoiceMessage(audioUrl, caption, timestamp) {
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
// Do NOT branch on sendBtnMode here: Enter should always send (so
|
||||
// typing "/cancel" submits normally). Cancel is wired only to the
|
||||
// send button's pointer click — see send-btn listener above.
|
||||
|
||||
const text = chatInput.value.trim();
|
||||
if (!text && pendingAttachments.length === 0) return;
|
||||
|
||||
@@ -1619,7 +1684,7 @@ function sendMessage() {
|
||||
renderAttachmentPreview();
|
||||
sendBtn.disabled = true;
|
||||
|
||||
const body = { session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString() };
|
||||
const body = { session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString(), lang: currentLang };
|
||||
if (attachments.length > 0) {
|
||||
body.attachments = attachments.map(a => ({
|
||||
file_path: a.file_path,
|
||||
@@ -1641,7 +1706,13 @@ function sendMessage() {
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
if (data.stream) {
|
||||
if (data.inline_reply) {
|
||||
// Channel handled synchronously (e.g. /cancel fast-path);
|
||||
// render as a bot bubble and skip SSE entirely.
|
||||
loadingEl.remove();
|
||||
addBotMessage(data.inline_reply, new Date());
|
||||
} else if (data.stream) {
|
||||
setSendBtnCancelMode(data.request_id);
|
||||
startSSE(data.request_id, loadingEl, timestamp, titleInfo);
|
||||
} else {
|
||||
loadingContainers[data.request_id] = loadingEl;
|
||||
@@ -1649,12 +1720,14 @@ function sendMessage() {
|
||||
} else {
|
||||
loadingEl.remove();
|
||||
addBotMessage(t('error_send'), new Date());
|
||||
resetSendBtnSendMode();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.name === 'AbortError') {
|
||||
loadingEl.remove();
|
||||
addBotMessage(t('error_timeout'), new Date());
|
||||
resetSendBtnSendMode();
|
||||
return;
|
||||
}
|
||||
if (attempt < MAX_RETRIES) {
|
||||
@@ -1664,6 +1737,7 @@ function sendMessage() {
|
||||
}
|
||||
loadingEl.remove();
|
||||
addBotMessage(t('error_send'), new Date());
|
||||
resetSendBtnSendMode();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1919,14 +1993,33 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
|
||||
stepsEl.appendChild(wrap);
|
||||
scrollChatToBottom();
|
||||
|
||||
} else if (item.type === 'cancelled') {
|
||||
// Agent acknowledged the stop; mark the bubble. A trailing
|
||||
// "done" still arrives with the partial answer.
|
||||
ensureBotEl();
|
||||
if (currentReasoningEl) {
|
||||
finalizeThinking(currentReasoningEl, reasoningStartTime, reasoningText);
|
||||
currentReasoningEl = null;
|
||||
reasoningText = '';
|
||||
}
|
||||
if (!botEl.querySelector('.agent-cancelled-tag')) {
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'agent-cancelled-tag text-xs text-amber-600 dark:text-amber-400 mt-1';
|
||||
tag.textContent = (currentLang === 'zh') ? '已中止' : 'Cancelled';
|
||||
stepsEl.appendChild(tag);
|
||||
}
|
||||
resetSendBtnSendMode();
|
||||
|
||||
} else if (item.type === 'done') {
|
||||
// Don't close the stream yet: the backend keeps it open
|
||||
// for a short tail to deliver async attachments such as
|
||||
// TTS audio (`voice_attach`). It will close the stream on
|
||||
// its own via onerror once the tail expires.
|
||||
done = true;
|
||||
resetSendBtnSendMode();
|
||||
|
||||
const finalText = item.content || accumulatedText;
|
||||
const finalTextRaw = item.content || accumulatedText;
|
||||
const finalText = localizeCancelMarker(finalTextRaw);
|
||||
|
||||
if (!botEl && finalText) {
|
||||
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
|
||||
@@ -1934,7 +2027,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
|
||||
} else if (botEl) {
|
||||
contentEl.classList.remove('sse-streaming');
|
||||
if (finalText) contentEl.innerHTML = renderMarkdown(finalText);
|
||||
contentEl.dataset.rawMd = finalText || '';
|
||||
contentEl.dataset.rawMd = finalTextRaw || '';
|
||||
const copyBtn = botEl.querySelector('.copy-msg-btn');
|
||||
if (copyBtn && finalText) copyBtn.style.display = '';
|
||||
applyHighlighting(botEl);
|
||||
@@ -1964,6 +2057,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
|
||||
delete activeStreams[requestId];
|
||||
if (loadingEl) { loadingEl.remove(); loadingEl = null; }
|
||||
addBotMessage(t('error_send'), new Date());
|
||||
resetSendBtnSendMode();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2000,6 +2094,7 @@ function startSSE(requestId, loadingEl, timestamp, titleInfo) {
|
||||
applyHighlighting(botEl);
|
||||
bindChatKnowledgeLinks(botEl);
|
||||
}
|
||||
resetSendBtnSendMode();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2238,13 +2333,23 @@ function _renderSentFileFromToolResult(step) {
|
||||
`<i class="fas fa-file-download" style="color:#6b7280;"></i> ${escapeHtml(fileName)}</a></div>`;
|
||||
}
|
||||
|
||||
// Cosmetic translator for cancel markers persisted in history.
|
||||
// History keeps the English canonical form for the LLM; only display is localized.
|
||||
function localizeCancelMarker(text) {
|
||||
if (!text) return text;
|
||||
if (currentLang !== 'zh') return text;
|
||||
return text
|
||||
.replace(/_\(Cancelled by user\)_/g, '_(用户已中止)_')
|
||||
.replace(/_\(Cancelled\)_/g, '_(已中止)_');
|
||||
}
|
||||
|
||||
function createBotMessageEl(content, timestamp, requestId, msg) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
|
||||
if (requestId) el.dataset.requestId = requestId;
|
||||
|
||||
let stepsHtml = '';
|
||||
let displayContent = content;
|
||||
let displayContent = localizeCancelMarker(content);
|
||||
|
||||
if (msg && msg.steps && msg.steps.length > 0) {
|
||||
// New format: ordered steps with interleaved content
|
||||
|
||||
@@ -93,6 +93,15 @@ def _require_auth():
|
||||
json.dumps({"status": "error", "message": "Unauthorized"}))
|
||||
|
||||
|
||||
# Localized text for /cancel system replies. Web is the only channel that
|
||||
# honors a per-request `lang`; other channels reply in Chinese by default.
|
||||
def _cancel_reply_text(cancelled: int, lang: str) -> str:
|
||||
en = lang.startswith("en")
|
||||
if cancelled > 0:
|
||||
return "🛑 Cancelled." if en else "🛑 已中止"
|
||||
return "Nothing to cancel." if en else "当前没有可中止的任务。"
|
||||
|
||||
|
||||
def _get_upload_dir() -> str:
|
||||
from common.utils import expand_path
|
||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
@@ -437,6 +446,18 @@ class WebChannel(ChatChannel):
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
|
||||
elif event_type == "agent_cancelled":
|
||||
# Push an explicit cancelled SSE event so the frontend
|
||||
# marks the bubble as stopped. A trailing "done" still
|
||||
# arrives with the partial answer.
|
||||
final_response = data.get("final_response", "")
|
||||
q.put({
|
||||
"type": "cancelled",
|
||||
"content": final_response,
|
||||
"request_id": request_id,
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
|
||||
elif event_type == "agent_end":
|
||||
# Safety net: if the agent finishes with an empty final_response,
|
||||
# chat_channel skips _send_reply (because reply.content is empty),
|
||||
@@ -756,6 +777,25 @@ class WebChannel(ChatChannel):
|
||||
# desire_rtype concept used by other channels).
|
||||
is_voice_input = bool(json_data.get('is_voice', False))
|
||||
|
||||
# Fast path for /cancel: bypass the session queue and SSE setup.
|
||||
# Web frontend (stream=true) only listens to SSE, so we return an
|
||||
# inline_reply payload to be rendered synchronously.
|
||||
stripped_prompt = (prompt or "").strip().lower()
|
||||
if stripped_prompt == "/cancel":
|
||||
from agent.protocol import get_cancel_registry
|
||||
cancelled = get_cancel_registry().cancel_session(session_id)
|
||||
lang = (json_data.get('lang') or 'zh').lower()
|
||||
msg_text = _cancel_reply_text(cancelled, lang)
|
||||
logger.info(
|
||||
f"[WebChannel] /cancel fast-path: session={session_id}, cancelled={cancelled}, lang={lang}"
|
||||
)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"request_id": "",
|
||||
"stream": False,
|
||||
"inline_reply": msg_text,
|
||||
})
|
||||
|
||||
# Append file references to the prompt (same format as QQ channel)
|
||||
if attachments:
|
||||
file_refs = []
|
||||
@@ -862,6 +902,11 @@ class WebChannel(ChatChannel):
|
||||
if itype == "done":
|
||||
post_done = True
|
||||
post_deadline = time.time() + POST_DONE_TAIL_SECONDS
|
||||
elif itype == "cancelled":
|
||||
# Close SSE tail quickly after cancel; don't wait for the
|
||||
# full TTS tail since the user already pressed Stop.
|
||||
post_done = True
|
||||
post_deadline = time.time() + 3
|
||||
elif itype == "voice_attach":
|
||||
# WSGI buffers the previous chunk until the next yield;
|
||||
# shrink the tail so the generator wakes up quickly to
|
||||
@@ -872,6 +917,59 @@ class WebChannel(ChatChannel):
|
||||
finally:
|
||||
self.sse_queues.pop(request_id, None)
|
||||
|
||||
def cancel_request(self):
|
||||
"""
|
||||
Cancel an in-flight agent run.
|
||||
|
||||
Body: {"request_id": "...", "session_id": "..."}
|
||||
Either field is sufficient; request_id is preferred when known.
|
||||
Always returns success even when nothing was running, so the
|
||||
client's UX is idempotent.
|
||||
"""
|
||||
try:
|
||||
from agent.protocol import get_cancel_registry
|
||||
|
||||
data = web.data()
|
||||
try:
|
||||
json_data = json.loads(data) if data else {}
|
||||
except Exception:
|
||||
json_data = {}
|
||||
|
||||
request_id = (json_data.get("request_id") or "").strip()
|
||||
session_id = (json_data.get("session_id") or "").strip()
|
||||
lang = (json_data.get("lang") or "zh").lower()
|
||||
|
||||
registry = get_cancel_registry()
|
||||
cancelled = 0
|
||||
|
||||
if request_id:
|
||||
if registry.cancel_request(request_id):
|
||||
cancelled = 1
|
||||
|
||||
if cancelled == 0 and session_id:
|
||||
cancelled = registry.cancel_session(session_id)
|
||||
|
||||
if request_id and request_id in self.sse_queues:
|
||||
self.sse_queues[request_id].put({
|
||||
"type": "cancelled",
|
||||
"content": "Cancelled" if lang.startswith("en") else "已中止",
|
||||
"request_id": request_id,
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
|
||||
logger.info(
|
||||
f"[WebChannel] cancel request: request_id={request_id!r}, "
|
||||
f"session_id={session_id!r}, cancelled={cancelled}"
|
||||
)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"cancelled": cancelled,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] cancel_request error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def poll_response(self):
|
||||
"""
|
||||
Poll for responses using the session_id.
|
||||
@@ -967,6 +1065,7 @@ class WebChannel(ChatChannel):
|
||||
'/api/voice/tts', 'VoiceTtsHandler',
|
||||
'/poll', 'PollHandler',
|
||||
'/stream', 'StreamHandler',
|
||||
'/cancel', 'CancelHandler',
|
||||
'/chat', 'ChatHandler',
|
||||
'/config', 'ConfigHandler',
|
||||
'/api/models', 'ModelsHandler',
|
||||
@@ -1240,6 +1339,12 @@ class PollHandler:
|
||||
return WebChannel().poll_response()
|
||||
|
||||
|
||||
class CancelHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
return WebChannel().cancel_request()
|
||||
|
||||
|
||||
class StreamHandler:
|
||||
def GET(self):
|
||||
_require_auth()
|
||||
|
||||
@@ -440,6 +440,17 @@ class WecomBotChannel(ChatChannel):
|
||||
state["current"] = ""
|
||||
_push_stream(state, force=True)
|
||||
|
||||
elif event_type == "agent_cancelled":
|
||||
# Flush partial output and strip trailing "---" separator
|
||||
# left over from previous turn, to avoid a dangling divider.
|
||||
if state["current"]:
|
||||
state["committed"] += state["current"]
|
||||
state["current"] = ""
|
||||
state["committed"] = state["committed"].rstrip()
|
||||
if state["committed"].endswith("---"):
|
||||
state["committed"] = state["committed"][:-3].rstrip()
|
||||
_push_stream(state, force=True)
|
||||
|
||||
return on_event
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user