手撸一个可以语音操作高德地图的AI智能体

前言

想象一下这样的场景,你有一个高德地图开发的大屏,上面布满了数据,这个数据可能是车辆位置相关信息,也可能是人员分布信息,而传统的大屏,想看什么数据都需要手动操作鼠标找到指定的数据源,而你的大屏,结合了AI,不用手、动动嘴皮子就可以将所有的数据,分门别类的展示在地图上。甭说别的,拉投资都能多拉一个小目标~

本篇文章主要利用高德地图、coze、腾讯实时语音功能开发一个可以通过语音或者文字对高德地图的操作,你可以利用语音或文字调用高德地图API,也可以通过语音操作获取你想要的数据并展示在地图上,让我们开始吧~

技术栈

  • vite 4.3.2
  • nodejs 18.19.1
  • @amap/amap-jsapi-loader 1.0.1

演示地址

演示视频地址

在线体验地址

正文

强烈建议:开始之前可以先看一下演示视频,或者自己体验一下,可以更理解下文的内容

注:在线体验地址默认不支持语音(默认支持文字输入),如需支持语音在地址后拼接 '?id=your_SecretId&key=your_SecretKey'

布局搭建

整体布局大概如下:左侧是渲染的高德地图区域,右侧为智能体对话框,对话框中包含对话内容、对话时间、清除聊天记录功能以及文本时输入框。当长按语音按钮时候,则会将腾讯实时对讲的内容反馈在对话框中,并在鼠标松开的时候生成一条用户的对话内容。

大家都是前端的高手高高手,这里就不赘述如何生成对话内容了,我们直接来看智能体的搭建吧。(代码大部分都是用cursor写的)

coze工作流

主要的工作流有两个,amap_answer是用于解析用户输入的内容,将自然语言转为机器语言,这个工作流为直接输出,input_answer用于操作地图或者数据后的结果反馈,这个工作流为流式输出

amap_answer工作流程

假设用户输入"将地图中心定位到北京":将该内容提供给ai分析节点,ai解析出意图,输出一个action,ai匹配到action为handleMap,再通过选择器节点进行分流,调用城市名称转经纬度的插件节点,在获取到经纬度后,最后在结果节点将获取到的内容进行输出

typescript 复制代码
一些相关的默认参数
"center": {{center}}
"pitch":{{pitch}}
"zoom": {{zoom}}

# action 操作地图
## desc 放大功能、缩小功能、调整角度、定位等均符合当前action
## output {
/*action的具体内容*/
"action": "handleMap",
/*返回的参数*/
    "params": {
        /*操作的类型*/
        "type":Array<"pthch"|"zoom"|"center">,
        /*  角度 */
        "pitch": number,  //
        /*显示级别 1-22 在这范围以外的数值取距离限制最近的那个值,如果是微调(没有具体数值只说放大或者缩小或者放大一点缩小一些这种类似的描述)则根据默认的参数进行微调*/
        "zoom": number, 
        /*这是地图的中心点可以定位到某个城市或者地点名*/ 
        "center": string
    }
}

// 通过分析用户输入的{{input}}内容,理解内容并结合以上描述的desc字段,找到符合的action,整理对应的参数并设置参数,最终输出一段序列化的json,不允许输出其他内容

以上是AI匹配handleMap动作的提示词,其他操作也是类似的内容,让ai输出带有action的json字符串,在格式化AI代码节点将json字符串转为json结构的元数据,并输出出来给其他节点使用

这里的元数据结构的定义一定要严格按照输出的内容写,不存在于元数据格式的内容是不会输出的,在后面编辑插件的时候也需要这样做。

前面指令输出的action为handleMap匹配到选择器节点第一个条件分支,并调用了通过地名调用经纬度的插件,那么我们来看一下这个插件是如何调用高德地图API的

首先我们创建一个工作流,环境就选前端相对熟悉的node.js,将必填项填写好后会进入工作流编辑器界面,在编辑器中添加以下代码

typescript 复制代码
import { Args } from '@/runtime';
const axios = require('axios');
import { Input, Output } from "@/typings/getLnglatByCity/getLnglatByCity";

/**
  * Each file needs to export a function named `handler`. This function is the entrance to the Tool.
  * @param {Object} args.input - input parameters, you can get test input value by input.xxx.
  * @param {Object} args.logger - logger instance used to print logs, injected by runtime
  * @returns {*} The return data of the function, which should match the declared output parameters.
  * 
  * Remember to fill in input/output in Metadata, it helps LLM to recognize and use tool.
  */
const lnglatDef = {
  lng: 0,
  lat:0
}

const getLnglat = ( city: string)=>{
  if(!city) {
    return lnglatDef
  }
  return new Promise((res,reg)=>{
    // 构造请求 URL
    const url = `https://restapi.amap.com/v3/geocode/geo?address=${city}&key=${your_apikey}`;
// 发送请求
axios.get(url)
  .then(response => {
    const data = response.data;
    console.log(data)
    if (data.status === '1' && data.geocodes.length > 0) {
      const location = data.geocodes[0].location;
      const [lng, lat] = location.split(',');
      console.log(`经度: ${lng}`);
      console.log(`纬度: ${lat}`);
      res({lng,lat})
    } else {
      console.log('未找到该地址的经纬度信息');
      reg(lnglatDef)
    }
  })
  .catch(error => {
    console.error('请求出错:', error);
    reg(lnglatDef)
  });
    
  })
}

// var geocoder = new map.Geocoder({
//   city:city, //城市设为北京,默认:"全国"
// });
// console.log('geocoder',geocoder)
export async function handler({ input, logger }: Args<Input>): Promise<Output> {
  const city = input?.city
  const a: any = await getLnglat(city);
  return {
    lnglat: `${a.lng},${a.lat}`
  }
};

这里是插件的完整代码,让我们逐步分析一下具体做了什么,通过input参数获取到城市的名称,并调用getLnglat方法。

const url = 'https://restapi.amap.com/v3/geocode/geo?address=${city}&key=${your_apikey}';,这个是高德地图提供的Web服务 API,除了地理解析,高德地图还提供了非常丰富的api。

编写完代码以后,可以尝试试运行一下,看看工作流调用是否成功。

如此便可完整的实现一个调用工作流输出对应数据的完整流程,在页面调用该工作流即可获取工作流输出的内容,关于如何调用工作流和处理数据,在后文说明。

input_answer工作流程

相对于amap_answer工作流,当前的工作流就简单的多,输入用户的问题、amap_answer反馈的数据、是否成功,将这些信息一股脑的投给AI,ai根据相关内容进行回答。

调用工作流

工作流调试完毕后,将在代码中进行调用。由于coze提供的直接输出工作流和流式输出工作流是不同的api,代码中也对应做出不同的调用和解析处理,代码中将这两个方法集合到一个公共方法中,通过传入不同的参数区分具体是直接输出,还是流式输出

typescript 复制代码
/**
 * 
 * @param url 调用地址
 * @param body 请求体
 * @param callback 回调
 * @param isStream 是否支持工作流回复
 * @param method 请求方法
 * @param chat 是否支持工作流回复
 * @returns 
 */
export const cozeRequest = (url: string, body: string, callback?: (value: any) => void, isStream = false, method = 'POST', chat = false) => {
    return new Promise((resolve, reject) => {
        try {
            const xhr = new XMLHttpRequest();
            xhr.open(method, url);
            xhr.setRequestHeader('Content-Type', 'application/json');
            xhr.setRequestHeader("Authorization", 'Bearer pat_XxfBVJT2PmvMDd9ChHArzMXETrpeIjjU8hdJgDFA22lHP7szAOBcyuvgVjHDHTMj');
            // xhr.setRequestHeader("Authorization", 'Bearer pat_dAqjlGvLpkgFJxmE819035qTIGiXlqv0ep521s9fVbVoxEiYagAtYg4muVpJDtVC');
            xhr.responseType = 'text';
            if (isStream) {
                xhr.onreadystatechange = () => {
                    
                    const content = chat ? formatChatResponse(xhr.response) : formatXhrResponse(xhr.response);
                    console.log('智能体调用',content);
                    callback && callback(content);
                };
            } else {
                xhr.onprogress = (e) => {
                    console.log('Progress response', e, xhr.response);
                    if (xhr.status >= 200 && xhr.status < 300) {
                        resolve(xhr.response);
                        callback && callback(xhr);
                    } else {
                       ...
                    }
                };
            }

            ...
            xhr.send(body);
        } catch (error) {
            ...
        }
    });
}

直接输出与流式输出

typescript 复制代码
// 自然语言转为机器语言
export const naturalLanguageToMachineLanguage = (parameters:WorkflowStreamRunBody['parameters']): any => {
    const body = {
        workflow_id: 'your_workflow_id',
        parameters
    }
    return new Promise((resolve, reject) => {
        PostWorkflowRun(body, (xhr) => {
            const output = handleWorkXhrResponse(xhr)?.output||"{}"
            if (output) {
                console.log('output',output);
                
                try {
                    const json = JSON.parse(output);
                    if (typeof json === 'object' && json !== null) {
                        console.log('json格式校验通过', json);
                        resolve(json);
                    } else {
                       ...
                    }
                } catch (error) {
                   ...
                }
            }
        })
    });
}

上述方法为调用直接输出工作流,PostWorkflowRun中使用fetch调用了 api.coze.cn/v1/workflow... 接口,这是coze提供的调用直接输出工作流的api,由于工作流返回的是文本格式,我们还需要handleWorkXhrResponse方法将工作流返回的内容处理为json格式,

typescript 复制代码
// 处理工作流返回结果 返回json
export const handleWorkXhrResponse = (xhr: any) => {
    
    const error = () => {
        const chineseCharacters = JSON.stringify(xhr)?.match?.(/[\u4e00-\u9fa5]/g);
        console.error(chineseCharacters || '')
    }
    try {
        const output = JSON.parse(JSON.parse(xhr?.response)?.data || "{}")
        if (output) {
            return output
        } 
        ...
    } catch (e) {
        ...
    }
}

调用流式输出工作流也是同理,只不过调用的api是https://api.coze.cn/v1/workflow/stream_run,下面两张图是不同返回方式工作流返回的不同格式

从图中可以看到,采用直接输出方式的工作流返回值就是一段在工作流中处理好的json序列化后的结果,我们只需将这些内容使用前文提到的handleWorkXhrResponse方法解析出来即可。

而流式输出的内容相对比较复杂一些,是一点一点输出出来的,这也就形成了我们在体验地址中看到的ai回答的内容是一步步输出出来的。其中event是流的输出状态,Message为正在输出的消息,Done为当前回答已输出完毕,可以终止当前对话

typescript 复制代码
/**
 * 
 * @param inputStr 
 * 调用流式输出内容
 * @returns 
 */
export const formatChatResponse = (inputStr: string) => {
    // 用于存储提取结果的数组
    const result: any[] = [];

    // 按行分割输入字符串
    const lines = inputStr.split('\n');
    let currentData: string | null = null;

    for (const line of lines) {
        if (line.startsWith('event:conversation.message.delta')) {
            currentData = line;
        } else if (line.startsWith('data:')) {
            if (currentData) {
                const jsonStr = line.slice('data:'.length);
                try {
                    const json = JSON.parse(jsonStr);
                    result.push({
                        event: "Message",
                        content: json.content
                    });
                } catch (error) {
                   ...
                }
                currentData = null;
            }
        } else if (line.startsWith('event:done')) {
            result.push({
                event: 'Done',
                content: ""
            });
        }
    }

    console.log(result);
    return result
}

操作高德地图

将智能体返回的内容解析完毕以后要做什么呢?当然是操作地图啦,还拿 action: "handleMap'为例,正如上图中ai的输出格式

typescript 复制代码
answeJson: {
    action: "handleMap",
    params:{
        ...
        type: ["center",'zoom'],
        zoom: 5,
    },
    lnglat: '116,39'
}

这里我偷了个懒,lnglat属性本该在params中,但是params和lnglat分属不同的节点,所以在最后结束节点的时候没有整理到一起,将就用~

typescript 复制代码
 const outputJson = await naturalLanguageToMachineLanguage(params);
const answerJson = outputJson?.answerJson
if (answerJson?.action === 'handleMap') {
    handleMap(outputJson, () => {
    // 将结果和用户输入的问题一并输入到流式输出的工作流,得到反馈
        askAi(params.input, JSON.stringify(outputJson))
    })
} 
// 匹配其他action
...

通过调用naturalLanguageToMachineLanguage方法,执行amap_answer工作流,将自然语言解析为机械语言,并匹配action。进行不同的操作

typescript 复制代码
export const handleMap = (outputJson: AnswerType, callback?: () => void) => {
    const answerJson = outputJson?.answerJson
    const { type } = answerJson.params;
    // 缩放等级
    if (type?.includes('zoom')) {
        answerJson?.params?.zoom && map.setZoom(answerJson?.params?.zoom)
    }
    //    设置中心点
    if (type?.includes('center')) {
        const pos = outputJson?.lnglat?.split(',') || []
        outputJson?.lnglat && answerJson?.params?.center && map.setCenter(pos)
        setMapMarker(pos)
    }
    //    设置俯仰角
    if (type?.includes('pitch')) {
        answerJson?.params?.pitch && map.setPitch(answerJson?.params?.pitch)
    }


    callback && callback()
}

对于高德地图的操作,简单的介绍这些,视频中关于绘制车辆实时定位、轨迹动画等均可在演示案例中找到

开发过程中走过的弯路

尝试在coze中构建AMap

目前调用的工作流都是接受和输出字符串或者json字符串,又要解析,又要匹配操作,还是比较繁琐,所以我在最开始写的时候尝试在coze的nodejs编辑器中创建一个AMap实例,但是...

构建高德地图实例AMap.Map的时候需要传入一个dom元素的id名称,进行dom的获取,这些dom的操作指令都是挂载在window上的,而nodejs要想支持window需要用到插件jsdom,所以我装了,用了,代码也写了,运行的时候报window不存在,所以这条路是走不通的,也或者是我的方法不对。

javascript 复制代码
const { JSDOM } = require('jsdom');
const AMapLoader = require('amap-jsapi-loader');

···
// 调用 AMapLoader.load 方法
AMapLoader.load({
    key: amapKey,
    version: '2.0',
    plugins: []
}).then((AMap) => {
    console.log('高德地图加载成功');
    // 在这里可以进行地图相关操作
    // 例如创建地图实例
    const map = new AMap.Map('container', {
        zoom: 10,
        center: [116.397428, 39.90923]
    });

···

coze直接返回调用高德地图api字符串的

首先我们新写一个工作流,然后用这个工作流匹配缩放的功能,并直接在code中设置这个缩放等级,也就是说code的内容是一段可执行的代码,这样我们在得到这段代码的时候可以直接用eval()来执行,这个方法是可行的,而且效果不错,有几个缺点

  • 首先这种字符串只可进行一些简单的操作,如果场景相对复杂,比如涉及到变量、作用域、定时器等内容都是会影响操作的。
  • 其次,这里输出的可执行代码的未定义变量一定要在调用eval之前定义好,比如这个map,如果逻辑太复杂,各种依赖调用,方法回调等,根本应付不了,而且不方便调试,
  • 最后一点就是安全性的问题,如果说前面两个问题都可以克服,那这个问题绝对是绕不过去的,代码注入的风险,如果将来有幸某个仁兄公司真的按照这种方式立项了,干到生产环境了,那被攻击的几率大大增加。

在尝试过一个操作地图后,就放弃了。

给大家展示一下用eval执行js命令的效果

好啦 结束啦~ 希望大家看演示视频的时候支持一下作者

相关推荐
we19a0sen2 分钟前
npm 常用命令及示例和解析
前端·npm·node.js
倒霉男孩2 小时前
HTML视频和音频
前端·html·音视频
喜欢便码2 小时前
JS小练习0.1——弹出姓名
java·前端·javascript
chase。2 小时前
【学习笔记】MeshCat: 基于three.js的远程可控3D可视化工具
javascript·笔记·学习
暗暗那2 小时前
【面试】什么是回流和重绘
前端·css·html
小宁爱Python2 小时前
用HTML和CSS绘制佩奇:我不是佩奇
前端·css·html
weifexie3 小时前
ruby可变参数
开发语言·前端·ruby
千野竹之卫3 小时前
3D珠宝渲染用什么软件比较好?渲染100邀请码1a12
开发语言·前端·javascript·3d·3dsmax
sunbyte3 小时前
初识 Three.js:开启你的 Web 3D 世界 ✨
前端·javascript·3d
半兽先生3 小时前
WebRtc 视频流卡顿黑屏解决方案
java·前端·webrtc