feat(agent): 智能体接入工具调用 + 园区知识库(RAG)
后端: - tools.py:工具注册表(get_park_overview 园区实时数据 / query_companies 企业名录 / control_display 大屏控制 / get_time),POST /api/tools/exec 执行 - rag.py:园区知识库(park.md 分块 → dashscope text-embedding-v3 → numpy 余弦检索),/api/kb/brief 摘要注入 + /api/kb/retrieve 动态检索 - 知识索引 kb_index.json 入库,部署免首次构建 前端(VoiceAssistant): - TOOL_DEFS 经 session.update 传给 LLM;toolcall 事件 → 后端执行 → sendToolOutput + requestResponse - instructions 拼接园区知识摘要;工具调用气泡提示 - 测试通过:4 个工具,检索「入驻政策」正确命中政策段落
This commit is contained in:
@@ -16,10 +16,40 @@ import './voice-original.css';
|
||||
import './voice-overrides.css';
|
||||
|
||||
const VOICE_URL = window.__DPM_VOICE_WS__ || `ws://${window.location.hostname}:8765/v1/realtime`;
|
||||
const DEFAULT_INSTRUCTIONS = '你是园区智能助手,请用简洁专业的中文回答,不超过三句话。';
|
||||
const DEFAULT_INSTRUCTIONS = '你是 PineSound 园区智能语音助手。可用工具:get_park_overview(园区实时数据)、query_companies(企业名录)、control_display(大屏控制)、get_time(时间)。涉及园区数据/企业/大屏控制时务必调用工具获取准确信息。请用简洁专业的中文回答,不超过三句话。';
|
||||
const DEFAULT_VOICE = 'Cherry';
|
||||
const DEFAULT_GATE_DB = -50;
|
||||
|
||||
// 工具定义(与后端 app/tools.py 的 tool_schemas 一致,经 session.update 传给 LLM)
|
||||
const TOOL_DEFS = [
|
||||
{
|
||||
type: 'function', name: 'get_park_overview',
|
||||
description: '获取园区实时运营概览:在园项目数、累计孵化企业、带动就业、营收(今日/累计)、设备在线率、在园人数、能耗等。回答园区数据类问题时使用。',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
type: 'function', name: 'query_companies',
|
||||
description: '查询园区入驻企业名录,支持按企业名关键词过滤。回答"有哪些企业/某企业是否入驻"时使用。',
|
||||
parameters: { type: 'object', properties: { keyword: { type: 'string', description: '企业名关键词,可为空字符串' } } },
|
||||
},
|
||||
{
|
||||
type: 'function', name: 'control_display',
|
||||
description: '控制大屏显示:switch_page 切换页面、alert 弹通知、media_play/media_pause 控制媒体播放。用户要求控制大屏时使用。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['switch_page', 'alert', 'media_play', 'media_pause'] },
|
||||
target: { type: 'string', description: '目标:页面路径(如 /、/twin、/ai、/voice)或通知文本' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function', name: 'get_time',
|
||||
description: '获取当前日期时间。用户问"现在几点/今天几号"时使用。',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
];
|
||||
|
||||
const STATE_VIEWS = {
|
||||
idle: '点击开始',
|
||||
connecting: '思考中',
|
||||
@@ -121,6 +151,33 @@ export default function VoiceAssistant() {
|
||||
console.warn('[vision] 自动问候已发送:', GREETING_TEXT);
|
||||
}, []);
|
||||
|
||||
// ── 工具调用执行器:LLM 请求工具 → 转发后端 /api/tools/exec → 结果回传 ──
|
||||
const onToolCall = useCallback(async (e) => {
|
||||
const { name, arguments: argsStr, callId } = e?.detail ?? {};
|
||||
if (!name || !callId) return;
|
||||
let args = {};
|
||||
try { args = JSON.parse(argsStr || '{}'); } catch { /* 忽略非法参数 */ }
|
||||
console.warn(`[tools] LLM 调用工具: ${name}`, args);
|
||||
pushMessage('assistant', `🔧 调用工具:${name}`, false);
|
||||
let output;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/tools/exec`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, args }),
|
||||
});
|
||||
const data = await res.json();
|
||||
output = data?.result ?? JSON.stringify(data);
|
||||
} catch (err) {
|
||||
output = JSON.stringify({ error: String(err) });
|
||||
}
|
||||
const c = clientRef.current;
|
||||
if (c) {
|
||||
c.sendToolOutput(callId, output);
|
||||
c.requestResponse();
|
||||
}
|
||||
}, [pushMessage]);
|
||||
|
||||
// ── 右下角气泡渲染(复刻原版 ui/chat.js)───────────────────────────────
|
||||
// 流程:新气泡插入 → rAF 加 `.in`(淡入)→ 4s 后加 `.out`(淡出)→ 400ms 移除。
|
||||
// 持续更新的气泡(partial 逐字流)续期 4s;栈上限 8 条,最旧优先淡出,
|
||||
@@ -257,11 +314,21 @@ export default function VoiceAssistant() {
|
||||
if (!audioCtxRef.current) audioCtxRef.current = new AudioContext();
|
||||
if (audioCtxRef.current.state === 'suspended') await audioCtxRef.current.resume();
|
||||
|
||||
// 知识库:拉取园区知识摘要拼进 instructions(静态知识一次注入;失败不影响对话)
|
||||
let instructions = DEFAULT_INSTRUCTIONS;
|
||||
try {
|
||||
const kbRes = await fetch(`${API_BASE}/api/kb/brief`, { cache: 'no-store' });
|
||||
const kbData = await kbRes.json();
|
||||
if (kbData?.ok && kbData.brief) {
|
||||
instructions = `${DEFAULT_INSTRUCTIONS}\n\n【园区知识库】\n${kbData.brief}`;
|
||||
}
|
||||
} catch { /* 知识库不可用不影响对话 */ }
|
||||
|
||||
const client = new S2sWsRealtimeClient({
|
||||
directUrl: VOICE_URL,
|
||||
voice: DEFAULT_VOICE,
|
||||
instructions: DEFAULT_INSTRUCTIONS,
|
||||
tools: [],
|
||||
instructions,
|
||||
tools: TOOL_DEFS,
|
||||
noiseGate: { enabled: true, thresholdDb: DEFAULT_GATE_DB },
|
||||
audioContext: audioCtxRef.current,
|
||||
micStream: stream,
|
||||
@@ -285,6 +352,7 @@ export default function VoiceAssistant() {
|
||||
}
|
||||
});
|
||||
client.addEventListener('input-level', (e) => paintInputLevel(e.detail?.rms));
|
||||
client.addEventListener('toolcall', (e) => void onToolCall(e));
|
||||
client.addEventListener('response-finished', () => {
|
||||
setState('listening');
|
||||
// 自动问候的回复播放完毕 → 启动"等待用户回答"倒计时;无回答则自动挂断
|
||||
@@ -308,7 +376,7 @@ export default function VoiceAssistant() {
|
||||
setErrorMsg(err?.message || String(err));
|
||||
setState('error');
|
||||
}
|
||||
}, [pushMessage, paintInputLevel, sendGreeting]);
|
||||
}, [pushMessage, paintInputLevel, sendGreeting, onToolCall]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
// 清理自动问候相关状态/倒计时
|
||||
|
||||
Reference in New Issue
Block a user