feat: add approve/reject buttons for wait_for_approval

- CommentSection shows explicit approve/reject buttons when waiting
- Reject aborts the workflow, approve continues with optional feedback
- Backend parses approved:/rejected: prefixes from comment content
This commit is contained in:
Fam Zheng
2026-03-07 16:41:15 +00:00
parent 938ba83f37
commit 47546a9d15
2 changed files with 91 additions and 11 deletions

View File

@@ -1177,7 +1177,27 @@ async fn run_agent_loop(
} }
}; };
tracing::info!("[workflow {}] Approval received: {}", workflow_id, approval_content); tracing::info!("[workflow {}] Approval response: {}", workflow_id, approval_content);
// Check if user rejected
if approval_content.starts_with("rejected:") {
let reason = approval_content.strip_prefix("rejected:").unwrap_or("").trim();
tracing::info!("[workflow {}] User rejected: {}", workflow_id, reason);
if let Some(step) = state.steps.iter_mut().find(|s| s.order == cur) {
step.status = StepStatus::Failed;
step.user_feedbacks.push(format!("用户终止: {}", reason));
}
log_execution(pool, broadcast_tx, workflow_id, cur, "wait_for_approval", "rejected", reason, "failed").await;
// Return error to end the agent loop; caller sets workflow to "failed"
return Err(anyhow::anyhow!("用户终止了执行: {}", reason));
}
// Approved — extract feedback after "approved:" prefix if present
let feedback = if approval_content.starts_with("approved:") {
approval_content.strip_prefix("approved:").unwrap_or("").trim().to_string()
} else {
approval_content.clone()
};
// Resume: restore Running status // Resume: restore Running status
if let Some(step) = state.steps.iter_mut().find(|s| s.order == cur) { if let Some(step) = state.steps.iter_mut().find(|s| s.order == cur) {
@@ -1197,8 +1217,13 @@ async fn run_agent_loop(
steps: plan_infos_from_state(&state), steps: plan_infos_from_state(&state),
}); });
let tool_msg = if feedback.is_empty() {
"用户已确认,继续执行。".to_string()
} else {
format!("用户已确认。反馈: {}", feedback)
};
state.current_step_chat_history.push( state.current_step_chat_history.push(
ChatMessage::tool_result(&tc.id, &format!("用户已确认。反馈: {}", approval_content)) ChatMessage::tool_result(&tc.id, &tool_msg)
); );
} }

View File

@@ -15,12 +15,8 @@ const emit = defineEmits<{
const input = ref('') const input = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null) const textareaRef = ref<HTMLTextAreaElement | null>(null)
function submit() { function buildText(): string {
if (props.disabled) return
const text = input.value.trim() const text = input.value.trim()
if (!text && !props.quotes.length) return
// Build final text: quotes as block references, then user text
let final = '' let final = ''
for (const q of props.quotes) { for (const q of props.quotes) {
final += `> ${q}\n` final += `> ${q}\n`
@@ -29,8 +25,27 @@ function submit() {
final += '\n' final += '\n'
} }
final += text final += text
return final.trim()
}
emit('submit', final.trim()) function submit() {
if (props.disabled) return
const text = input.value.trim()
if (!text && !props.quotes.length) return
emit('submit', buildText())
input.value = ''
}
function approve() {
const feedback = buildText()
emit('submit', feedback ? `approved: ${feedback}` : 'approved:')
input.value = ''
}
function reject() {
const feedback = buildText()
emit('submit', feedback ? `rejected: ${feedback}` : 'rejected: 用户终止')
input.value = '' input.value = ''
} }
@@ -53,7 +68,7 @@ defineExpose({ focusInput })
<template> <template>
<div class="comment-section" :class="{ 'waiting-approval': waitingApproval }"> <div class="comment-section" :class="{ 'waiting-approval': waitingApproval }">
<div v-if="waitingApproval" class="approval-banner"> <div v-if="waitingApproval" class="approval-banner">
Agent 正在等待你的确认请在下方输入反馈后发送 Agent 正在等待你的确认
</div> </div>
<div v-if="quotes.length" class="quotes-bar"> <div v-if="quotes.length" class="quotes-bar">
<div v-for="(q, i) in quotes" :key="i" class="quote-chip"> <div v-for="(q, i) in quotes" :key="i" class="quote-chip">
@@ -65,11 +80,15 @@ defineExpose({ focusInput })
<textarea <textarea
ref="textareaRef" ref="textareaRef"
v-model="input" v-model="input"
:placeholder="quotes.length ? '添加你的评论...' : '输入反馈或调整指令... (Ctrl+Enter 发送)'" :placeholder="waitingApproval ? '可选:附加反馈或修改意见...' : (quotes.length ? '添加你的评论...' : '输入反馈或调整指令... (Ctrl+Enter 发送)')"
rows="3" rows="3"
@keydown="onKeydown" @keydown="onKeydown"
/> />
<button class="btn-send" :disabled="disabled || (!input.trim() && !quotes.length)" @click="submit">发送</button> <div v-if="waitingApproval" class="approval-buttons">
<button class="btn-approve" @click="approve">继续执行</button>
<button class="btn-reject" @click="reject">终止</button>
</div>
<button v-else class="btn-send" :disabled="disabled || (!input.trim() && !quotes.length)" @click="submit">发送</button>
</div> </div>
</div> </div>
</template> </template>
@@ -178,4 +197,40 @@ defineExpose({ focusInput })
.btn-send:hover { .btn-send:hover {
background: var(--accent-hover); background: var(--accent-hover);
} }
.approval-buttons {
display: flex;
flex-direction: column;
gap: 6px;
}
.btn-approve {
background: #4caf50;
color: #fff;
font-weight: 600;
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.btn-approve:hover {
background: #43a047;
}
.btn-reject {
background: transparent;
color: var(--error, #ef5350);
font-weight: 500;
padding: 6px 20px;
border: 1px solid var(--error, #ef5350);
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.btn-reject:hover {
background: rgba(239, 83, 80, 0.1);
}
</style> </style>