feat: 新增 Web Search 工具 + 统一 tool_registry 分发 (#68)

主要功能:
- Web Search: Tavily API 搜索互联网获取真实数据,注册为 search_web 工具
- 统一工具注册: tool_registry 替代 if/elif 硬编码分发
- Evidence 数据模型: DataEvidence 结构化搜索结果

编排层(workflow.py)集成:
- Web Search 初始化与动态工具注册
- RAG 知识库检索与注入
- HIL 人机协作检查点
- Fallback Hand Off 容错机制
- Evaluator + Feedback Rerun 质量保障

新增配置:TAVILY_API_KEY / SEARCH_ENABLED / SEARCH_CACHE_TTL
所有功能均有独立开关,未配置时行为与升级前一致。
This commit is contained in:
Jiangming Zhong
2026-05-13 14:13:58 +08:00
committed by GitHub
parent 5305754f0b
commit d5428a2a2e
25 changed files with 2599 additions and 205 deletions
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { ref, computed, onUnmounted } from "vue";
// ---- Props ----
/** 审批消息数据 */
interface ApprovalData {
checkpoint_id: string;
prompt: Record<string, unknown>;
options: string[];
timeout: number;
}
// ---- State ----
const showDialog = ref(false);
const approvalData = ref<ApprovalData | null>(null);
const editContent = ref("");
const showEditArea = ref(false);
const showAskArea = ref(false);
const askContent = ref("");
const remainingSeconds = ref(0);
let resolvePromise: ((value: { action: string; content?: unknown }) => void) | null = null;
let countdownTimer: ReturnType<typeof setInterval> | null = null;
// ---- Computed ----
const promptDisplay = computed(() => {
if (!approvalData.value) return "";
const p = approvalData.value.prompt;
if (p.step) return `步骤: ${p.step}`;
if (p.subtask) return `子任务: ${p.subtask}`;
return JSON.stringify(p, null, 2);
});
const promptDetail = computed(() => {
if (!approvalData.value) return "";
const p = approvalData.value.prompt;
// 显示关键内容摘要
if (p.questions) return JSON.stringify(p.questions, null, 2).slice(0, 500);
if (p.solutions) return JSON.stringify(p.solutions, null, 2).slice(0, 500);
if (p.code_response) return String(p.code_response).slice(0, 500);
if (p.summary) return String(p.summary).slice(0, 500);
return "";
});
// ---- Methods ----
/** 打开审批对话框 */
function open(data: ApprovalData): Promise<{ action: string; content?: unknown }> {
approvalData.value = data;
editContent.value = "";
askContent.value = "";
showEditArea.value = false;
showAskArea.value = false;
showDialog.value = true;
remainingSeconds.value = data.timeout;
// 启动倒计时
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = setInterval(() => {
remainingSeconds.value = Math.max(0, remainingSeconds.value - 1);
if (remainingSeconds.value <= 0) {
handleAction("confirm");
}
}, 1000);
return new Promise((resolve) => {
resolvePromise = resolve;
});
}
/** 处理用户决策 */
function handleAction(action: string) {
cleanup();
showDialog.value = false;
const result: { action: string; content?: unknown } = { action };
if (action === "edit" && editContent.value) {
try {
result.content = JSON.parse(editContent.value);
} catch {
result.content = editContent.value;
}
} else if (action === "ask" && askContent.value) {
result.content = askContent.value;
}
resolvePromise?.(result);
resolvePromise = null;
}
/** 清理定时器 */
function cleanup() {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
onUnmounted(cleanup);
// ---- Expose ----
defineExpose({ open });
</script>
<template>
<Dialog v-model:open="showDialog">
<DialogContent class="max-w-lg">
<DialogHeader>
<DialogTitle>需要您的审批</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<!-- 检查点信息 -->
<div class="rounded-md bg-muted p-3">
<p class="text-sm font-medium">{{ promptDisplay }}</p>
<pre v-if="promptDetail" class="mt-2 max-h-40 overflow-auto text-xs text-muted-foreground whitespace-pre-wrap">{{ promptDetail }}</pre>
</div>
<!-- 倒计时 -->
<div class="text-sm text-muted-foreground text-center">
<span v-if="remainingSeconds > 0">
{{ remainingSeconds }}秒后自动继续
</span>
<span v-else class="text-orange-500">超时自动继续...</span>
</div>
<!-- 编辑区域 -->
<div v-if="showEditArea" class="space-y-2">
<p class="text-sm text-muted-foreground">请输入修改后的内容JSON 或文本:</p>
<Textarea v-model="editContent" rows="6" placeholder="输入修改后的内容..." />
</div>
<!-- 追问区域 -->
<div v-if="showAskArea" class="space-y-2">
<p class="text-sm text-muted-foreground">请输入补充信息:</p>
<Textarea v-model="askContent" rows="3" placeholder="输入补充信息..." />
</div>
</div>
<DialogFooter class="flex flex-wrap gap-2">
<Button v-if="!showEditArea && !showAskArea" variant="default" @click="handleAction('confirm')">
确认继续
</Button>
<Button v-if="!showEditArea && !showAskArea" variant="outline" @click="showEditArea = true">
修改内容
</Button>
<Button v-if="!showEditArea && !showAskArea" variant="outline" @click="handleAction('regenerate')">
重新生成
</Button>
<Button v-if="!showEditArea && !showAskArea" variant="outline" @click="showAskArea = true">
追问
</Button>
<Button v-if="!showEditArea && !showAskArea" variant="ghost" @click="handleAction('skip')">
跳过审核
</Button>
<Button v-if="!showEditArea && !showAskArea" variant="destructive" @click="handleAction('abort')">
中止任务
</Button>
<!-- 编辑/追问确认 -->
<Button v-if="showEditArea" variant="default" @click="handleAction('edit')">
提交修改
</Button>
<Button v-if="showEditArea" variant="ghost" @click="showEditArea = false">
取消
</Button>
<Button v-if="showAskArea" variant="default" @click="handleAction('ask')">
发送追问
</Button>
<Button v-if="showAskArea" variant="ghost" @click="showAskArea = false">
取消
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+37 -1
View File
@@ -1,6 +1,7 @@
import { getTaskMessages } from "@/apis/commonApi";
import { AgentType } from "@/utils/enum";
import type {
ApprovalMessage,
CoderMessage,
CoordinatorMessage,
InterpreterMessage,
@@ -36,6 +37,9 @@ export const useTaskStore = defineStore("task", () => {
/** WebSocket 实例 */
let ws: TaskWebSocket | null = null;
/** 审批消息回调(由 ApprovalDialog 注册) */
let onApprovalCallback: ((data: ApprovalMessage) => void) | null = null;
// ---- Helpers ----
/** 获取消息时间戳 */
@@ -68,10 +72,18 @@ export const useTaskStore = defineStore("task", () => {
return (
typeof Reflect.get(payload, "id") === "string" &&
typeof msgType === "string" &&
["system", "agent", "user", "tool"].includes(msgType)
["system", "agent", "user", "tool", "approval"].includes(msgType)
);
}
/** 类型守卫:判断是否为审批消息 */
function isApprovalMessage(payload: unknown): payload is ApprovalMessage {
if (!payload || typeof payload !== "object") {
return false;
}
return Reflect.get(payload, "msg_type") === "approval";
}
/** 设置当前活跃任务 */
function setCurrentTask(taskId: string) {
currentTaskId.value = taskId;
@@ -147,6 +159,12 @@ export const useTaskStore = defineStore("task", () => {
const wsUrl = `${baseUrl}/task/${taskId}`;
ws = new TaskWebSocket(wsUrl, (data) => {
// 处理审批消息
if (isApprovalMessage(data)) {
appendMessage(taskId, data);
onApprovalCallback?.(data);
return;
}
if (!isMessagePayload(data)) {
console.warn("忽略非标准任务消息:", data);
return;
@@ -301,6 +319,22 @@ export const useTaskStore = defineStore("task", () => {
// 如果需要自动连接,可以在这里添加代码
// 例如connectWebSocket('default')
/** 注册审批消息回调 */
function onApproval(callback: (data: ApprovalMessage) => void) {
onApprovalCallback = callback;
}
/** 通过 WebSocket 发送用户决策 */
function sendDecision(checkpointId: string, decision: { action: string; content?: unknown }) {
if (ws) {
ws.send({
type: "user_decision",
checkpoint_id: checkpointId,
decision,
});
}
}
return {
messages,
chatMessages,
@@ -315,5 +349,7 @@ export const useTaskStore = defineStore("task", () => {
closeWebSocket,
downloadMessages,
addUserMessage,
onApproval,
sendDecision,
};
});
+11 -1
View File
@@ -125,6 +125,15 @@ export interface WriterMessage extends AgentMessage {
sub_title?: string;
}
/** HIL 审批消息 */
export interface ApprovalMessage extends BaseMessage {
msg_type: "approval";
checkpoint_id: string;
prompt: Record<string, unknown>;
options: string[];
timeout: number;
}
/** 所有消息类型的联合类型 */
export type Message =
| SystemMessage
@@ -133,4 +142,5 @@ export type Message =
| WriterMessage
| ModelerMessage
| CoordinatorMessage
| ToolMessage;
| ToolMessage
| ApprovalMessage;