Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 40 additions & 34 deletions design/vue/src/composables/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function useChat() {
// ── 中断控制 ──
let abortController: AbortController | null = null

// ── 审批状态(危险工具中断) ──
// ── 审批/澄清状态(统一处理 interrupt) ──
const pendingApproval = ref<PendingApproval | null>(null)
// interrupt 事件所在的 assistant 消息块 ID,恢复流程复用同一个
let interruptedAssistantId = ''
Expand Down Expand Up @@ -246,7 +246,7 @@ export function useChat() {
loadMessages(sessionId)
}

// 切换会话时恢复/清除审批卡状态
// 切换会话时恢复/清除审批卡或澄清追问卡状态
watch(
() => activeSession.value,
(sess) => {
Expand All @@ -255,9 +255,11 @@ export function useChat() {
pendingApproval.value = {
checkpoint_id: pc.checkpoint_id,
interrupt_id: pc.interrupt_id,
title: '需要人工确认',
detail: pc.question ?? '执行被中断,等待用户审批',
title: pc.is_clarify ? '需要澄清' : '需要人工确认',
detail: pc.question ?? '执行被中断',
tool_name: pc.tool_name,
options: pc.options,
is_clarify: pc.is_clarify ?? false,
}
} else {
pendingApproval.value = null
Expand Down Expand Up @@ -459,34 +461,38 @@ export function useChat() {
progressText.value = ''
streamContent.value = ''
streamSources.value = []
// streamTimeline 不清空,interrupt 前的步骤保留,恢复后继续累加
const info = evt.interrupt_info ?? {}
const approval: PendingApproval = {
checkpoint_id: evt.checkpoint_id ?? '',
interrupt_id: evt.interrupt_id ?? '',
title: '需要人工确认',
detail: evt.detail ?? (info?.message as string) ?? '执行被中断,等待用户处理',
tool_name: (info?.tool_name as string) ?? '',
target_ref: (info?.target_ref as string) ?? '',
reason: (info?.reason as string) ?? '',
}
pendingApproval.value = approval
// 记录 assistant 块 ID,恢复时 done 事件复用同一块
interruptedAssistantId = assistantId || 'a-' + Date.now()
return
}

case 'clarify': {
isLoading.value = false
progressText.value = ''
const q = evt.clarify?.question ?? evt.detail ?? ''
const opts = evt.clarify?.options ?? []
messages.value.push({
id: 'c-' + Date.now(),
role: 'assistant',
content: q,
})
break
if (evt.status === 'clarify' || evt.clarify) {
// 澄清追问
const approval: PendingApproval = {
checkpoint_id: evt.checkpoint_id ?? '',
interrupt_id: evt.interrupt_id ?? '',
title: '需要澄清',
detail: evt.clarify?.question ?? evt.detail ?? '',
tool_name: '',
target_ref: '',
reason: evt.clarify?.context ?? '',
options: evt.clarify?.options ?? [],
is_clarify: true,
}
pendingApproval.value = approval
} else {
// 危险工具审批
const info = evt.interrupt_info ?? {}
const approval: PendingApproval = {
checkpoint_id: evt.checkpoint_id ?? '',
interrupt_id: evt.interrupt_id ?? '',
title: '需要人工确认',
detail: evt.detail ?? (info?.message as string) ?? '执行被中断,等待用户处理',
tool_name: (info?.tool_name as string) ?? '',
target_ref: (info?.target_ref as string) ?? '',
reason: (info?.reason as string) ?? '',
is_clarify: false,
}
pendingApproval.value = approval
}
return
}

case 'done':
Expand Down Expand Up @@ -647,12 +653,12 @@ export function useChat() {
}
}

// ── 危险工具审批 ──
function approvePending(resolution: 'approve' | 'reject') {
// ── 审批 / 澄清追问统一入口 ──
function approvePending(resolution: string) {
if (!pendingApproval.value) return
input.value = resolution // 请求内容
input.value = resolution
pendingApproval.value = null
void sendMessage(undefined, true) // isResume=true: 不 push 用户气泡
void sendMessage(undefined, true)
}

function cancelApproval() {
Expand Down
57 changes: 55 additions & 2 deletions design/vue/src/pages/ChatPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,53 @@
</div>
</div>

<!-- Pending Approval Card -->
<!-- Pending Interrupt Card: clarify 或 danger approval -->
<div v-if="pendingApproval" class="flex justify-start">
<div class="max-w-[80%]">
<!-- 澄清追问卡 -->
<div v-if="pendingApproval.is_clarify" class="max-w-[80%]">
<div class="rounded-xl border border-sky-200 bg-sky-50 shadow-sm overflow-hidden">
<div class="flex items-center gap-2 px-4 py-2.5 border-b border-sky-200 bg-sky-100/50">
<svg class="w-4 h-4 text-sky-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
<span class="text-xs font-semibold text-sky-800">需要澄清</span>
</div>
<div class="px-4 py-3">
<p class="text-sm text-sky-900 leading-relaxed">{{ pendingApproval.detail }}</p>
<!-- 选项按钮 -->
<div v-if="pendingApproval.options?.length" class="mt-3 flex flex-wrap gap-2">
<button
v-for="(opt, idx) in pendingApproval.options"
:key="idx"
@click="approvePending(opt)"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white hover:bg-sky-50 text-sky-700 border border-sky-200 transition-colors"
>{{ opt }}</button>
</div>
<!-- 自由输入 -->
<div class="mt-3">
<div class="flex gap-2">
<input
v-model="clarifyInput"
@keyup.enter="submitClarify"
placeholder="也可以直接输入你的回答..."
class="flex-1 px-3 py-1.5 text-xs rounded-lg border border-sky-200 bg-white text-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-300"
/>
<button
@click="submitClarify"
:disabled="!clarifyInput.trim()"
class="px-3 py-1.5 text-xs font-medium rounded-lg bg-sky-600 hover:bg-sky-700 disabled:bg-sky-300 text-white transition-colors"
>提交</button>
</div>
</div>
<button
@click="cancelApproval"
class="mt-2 px-3 py-1 text-xs text-slate-400 hover:text-slate-600"
>暂不处理</button>
</div>
</div>
</div>
<!-- 危险工具审批卡 -->
<div v-else class="max-w-[80%]">
<div class="rounded-xl border border-amber-200 bg-amber-50 shadow-sm overflow-hidden">
<div class="flex items-center gap-2 px-4 py-2.5 border-b border-amber-200 bg-amber-100/50">
<svg class="w-4 h-4 text-amber-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand Down Expand Up @@ -366,6 +410,15 @@ const {
const chatEl = ref<HTMLDivElement>()
const hasMessages = computed(() => messages.value.length > 0)

// ── 澄清追问自由输入 ──
const clarifyInput = ref('')
function submitClarify() {
const v = clarifyInput.value.trim()
if (!v) return
approvePending(v)
clarifyInput.value = ''
}

useMarkdownTooltip()

// ── 保存笔记到知识库 ──
Expand Down
4 changes: 4 additions & 0 deletions design/vue/src/types/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface PendingCheckpointInfo {
interrupt_id: string
question?: string
tool_name?: string
is_clarify?: boolean
options?: string[]
set_at: string
}

Expand Down Expand Up @@ -114,6 +116,8 @@ export interface PendingApproval {
tool_name?: string
target_ref?: string
reason?: string
options?: string[]
is_clarify?: boolean
}

// ── List Responses ──
Expand Down
10 changes: 10 additions & 0 deletions internal/agent/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,13 @@ func (e *Engine) dangerousToolNames() map[string]bool {
}
return m
}

func (e *Engine) clarifyToolNames() map[string]bool {
m := make(map[string]bool)
for _, entry := range e.internalTools {
if entry.Name == "ask_clarify" {
m[entry.Name] = true
}
}
return m
}
16 changes: 11 additions & 5 deletions internal/agent/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,18 @@ func (e *Engine) runAgent(ctx context.Context, req Request, chatModel model.Tool
return fmt.Sprintf("⚠️ 工具 %q 不存在,可用工具请查看系统提示。请检查工具名拼写后重试。", name), nil
},
}
// 有危险工具时注入审批中间件
// ── 注入中间件:危险工具审批 + 澄清追问 ──
var middlewares []compose.ToolMiddleware
if dangerousNames := e.dangerousToolNames(); len(dangerousNames) > 0 {
toolsNodeConfig.ToolCallMiddlewares = []compose.ToolMiddleware{
{Invokable: buildDangerousToolMiddleware(dangerousNames)},
}
logger.Infof("[Agent] 已注入危险工具审批中间件,工具列表=%v", dangerousNames)
middlewares = append(middlewares, compose.ToolMiddleware{Invokable: buildDangerousToolMiddleware(dangerousNames)})
logger.Infof("[Agent] 已注入危险工具审批中间件: %v", dangerousNames)
}
if clarifyNames := e.clarifyToolNames(); len(clarifyNames) > 0 {
middlewares = append(middlewares, compose.ToolMiddleware{Invokable: buildClarifyMiddleware(clarifyNames)})
logger.Infof("[Agent] 已注入澄清追问中间件: %v", clarifyNames)
}
if len(middlewares) > 0 {
toolsNodeConfig.ToolCallMiddlewares = middlewares
}

agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Expand Down
18 changes: 17 additions & 1 deletion internal/agent/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,27 @@ func buildReActSystemPrompt(ctx context.Context, allTools []einoTool.BaseTool, i
}
if hasDangerous {
sb.WriteString("7. **危险工具审批**:delete_document 等危险工具会在执行前暂停并等待用户审批,调用后流程中断,用户确认后自动继续\n")
sb.WriteString(" - ⚠️ **目标不明确先反问**:当用户说'删除那个文档'、'清理一下'、'把上面的删了'这类模糊指令,且从对话历史无法唯一确定目标时,**绝对不能编造参数调用工具**。先反问用户明确目标(例如:'你要删除的是《压力 - 07/13 16:03》那个文档吗?还是另一个?')\n")
sb.WriteString(" - ⚠️ **目标不明确先反问**:当用户说'删除那个文档'、'清理一下'、'把上面的删了'这类模糊指令,且从对话历史无法唯一确定目标时,**绝对不能编造参数调用工具**。先调用 ask_clarify 反问用户明确目标(例如:'你要删除的是《压力 - 07/13 16:03》那个文档吗?还是另一个?')\n")
sb.WriteString(" - ⚠️ **禁止猜测参数**:document_id 等关键参数必须来自可靠来源(用户明确提供、get_document_info 工具查询结果、历史对话中已确认的 ID)。严禁从模糊描述或'看起来像是'的文本中猜测或编造\n")
sb.WriteString(" - 调用危险工具时务必在参数里写清楚目标和原因,便于用户决策\n")
}

// 澄清追问说明
hasClarify := false
for _, entry := range internalSorted {
if entry.Name == "ask_clarify" {
hasClarify = true
break
}
}
if hasClarify {
sb.WriteString("9. **澄清追问(ask_clarify)**:当用户的指令/问题存在歧义、历史对话信息不足以唯一确定目标、或你不确定下一步该怎么做时,调用 ask_clarify 暂停执行并向用户提问\n")
sb.WriteString(" - 🎯 **触发场景**:用户说了'那个文档'、'再看一下'、'它'等指代但缺少明确上下文;或问题本身有多种理解方式;或缺少执行所需的关键信息\n")
sb.WriteString(" - 🎯 **参数格式**:question 必填(一句话,不超过 100 字);options 可选(最多 4 个选项,用户可点选也可自由输入);context 可选(为什么需要澄清)\n")
sb.WriteString(" - ⚠️ **不要滥用**:只有在无法从对话历史推断意图时才调用。明显的指令直接执行,不确定的先用检索工具找线索,真不行再澄清\n")
sb.WriteString(" - ⚠️ **澄清后恢复**:用户回答后流程自动恢复,你会收到用户的回答作为工具结果,基于回答继续完成任务\n")
}

// 外部联网工具
externals := make([]string, 0)
for _, td := range allDescs {
Expand Down
107 changes: 81 additions & 26 deletions internal/agent/runner_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,34 +96,60 @@ func (e *Engine) runWithRunner(
}

// ── Interrupt 处理 ──
if agentEvent.Action != nil && agentEvent.Action.Interrupted != nil {
if !interruptSent {
interruptSent = true
ii := agentEvent.Action.Interrupted
interruptCtx := ii.InterruptContexts
var interruptID string
var interruptInfo any
if len(interruptCtx) > 0 {
interruptID = interruptCtx[0].ID
interruptInfo = interruptCtx[0].Info
}
logger.Infof("[Agent] 执行中断,等待用户审批: checkpointID=%s, interruptID=%s, info=%v", checkpointID, interruptID, interruptInfo)
infoMap, _ := interruptInfo.(map[string]any)
eventCh <- Event{
Type: EventInterrupt,
Title: "需要人工确认",
Detail: truncateStr(formatInterruptInfo(interruptInfo), 256),
Status: "interrupt",
Error: interruptID,
CheckpointID: checkpointID,
InterruptID: interruptID,
InterruptInfo: infoMap,
Done: true,
if agentEvent.Action != nil && agentEvent.Action.Interrupted != nil {
if !interruptSent {
interruptSent = true
ii := agentEvent.Action.Interrupted
interruptCtx := ii.InterruptContexts
var interruptID string
var infoStr string
if len(interruptCtx) > 0 {
interruptID = interruptCtx[0].ID
if s, ok := interruptCtx[0].Info.(string); ok {
infoStr = s
}
}
logger.Infof("[Agent] 执行中断: checkpointID=%s, interruptID=%s, info=%s", checkpointID, interruptID, truncateStr(infoStr, 200))

infoType, infoData := parseInterruptInfo(infoStr)

if infoType == "clarify" {
eventCh <- Event{
Type: EventInterrupt,
Title: "需要澄清",
Detail: getString(infoData, "question"),
Status: "clarify",
Error: interruptID,
CheckpointID: checkpointID,
InterruptID: interruptID,
IsClarify: true,
ClarifyQuestion: getString(infoData, "question"),
ClarifyOptions: getStringSlice(infoData, "options"),
ClarifyContext: getString(infoData, "context"),
Done: true,
}
} else {
// danger 或未知类型 → 按审批处理
message := getString(infoData, "message")
if message == "" {
message = formatInterruptInfo(infoStr)
}
eventCh <- Event{
Type: EventInterrupt,
Title: "需要人工确认",
Detail: truncateStr(message, 256),
Status: "interrupt",
Error: interruptID,
CheckpointID: checkpointID,
InterruptID: interruptID,
InterruptInfo: infoData,
Done: true,
}
}
return
}
return
continue
}
continue
}

if agentEvent.Output == nil || agentEvent.Output.MessageOutput == nil {
continue
Expand Down Expand Up @@ -424,3 +450,32 @@ func buildFallbackAnswer(sources []tool.SourceDocument) string {
}



func getString(m map[string]any, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}

func getStringSlice(m map[string]any, key string) []string {
v, ok := m[key]
if !ok {
return nil
}
switch raw := v.(type) {
case []string:
return raw
case []any:
out := make([]string, 0, len(raw))
for _, item := range raw {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}
Loading