【Unity AI】基于 WebSocket 和 讯飞星火大模型

文章目录


整体

AIManager

负责配置讯飞的appId,生成鉴权URL,通过WebSocket向服务器请求并返回数据(分为最终返回和流式返回)

cs 复制代码
 public async void RequestAnswer(List<Content> dialogueContext, Action<string> completeCallback, Action<string> streamingCallback = null)
 {
     //获取鉴权URL
     string authUrl = GetAuthURL();
     string url = authUrl.Replace("http://", "ws://").Replace("https://", "wss://");

     using(webSocket = new ClientWebSocket())
     {
         try
         {
             //向服务器发起连接请求
             await webSocket.ConnectAsync(new Uri(url), cancellation);

             Debug.Log("成功连接服务器");

             //改变参数的上下文对话(这一块外包给了dialogueManager进行控制,因为要控制上下文长度)
             request.payload.message.text = dialogueContext;

             //将Json序列化为byte流
             string jsonString = JsonConvert.SerializeObject(request);
             byte[] binaryJsonData = Encoding.UTF8.GetBytes(jsonString.ToString());

             //向服务器发送数据
             _=webSocket.SendAsync(new ArraySegment<byte>(binaryJsonData), WebSocketMessageType.Text, true, cancellation);

             Debug.Log("已向服务器发送数据,正在等待消息返回...");

             //循环等待服务器返回内容
             byte[] receiveBuffer = new byte[1024];
             WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment<byte>(receiveBuffer), cancellation);
             String resp = "";
             while (!result.CloseStatus.HasValue)
             {
                 if (result.MessageType == WebSocketMessageType.Text)
                 {
                     string receivedMessage = Encoding.UTF8.GetString(receiveBuffer, 0, result.Count);

                     //将结果解释为Json(没有指定具体类型)
                     JObject jsonObj = JObject.Parse(receivedMessage);
                     int code = (int)jsonObj["header"]["code"];

                     if (0 == code)
                     {
                         int status = (int)jsonObj["payload"]["choices"]["status"];

                         JArray textArray = (JArray)jsonObj["payload"]["choices"]["text"];
                         string content = (string)textArray[0]["content"];
                         resp += content;

                         if (status == 2)
                         {
                             Debug.Log($"最后一帧: {receivedMessage}");
                             int totalTokens = (int)jsonObj["payload"]["usage"]["text"]["total_tokens"];
                             Debug.Log($"整体返回结果: {resp}");
                             Debug.Log($"本次消耗token数: {totalTokens}");

                             completeCallback(resp.TrimStart('\n'));
                             return;
                         }
                         else
                         {
                             streamingCallback?.Invoke(resp.TrimStart('\n'));
                         }
                     }
                     else
                     {
                         Debug.Log($"请求报错: {receivedMessage}");
                         if (code == 10013)
                             OnRequestBan?.Invoke();
                         break;
                     }
                 }
                 else if (result.MessageType == WebSocketMessageType.Close)
                 {
                     Debug.Log("已关闭WebSocket连接");
                     break;
                 }

                 result = await webSocket.ReceiveAsync(new ArraySegment<byte>(receiveBuffer), cancellation);
             }
         }
         catch (Exception e)
         {
             Debug.LogError(e.ToString());
         }

         completeCallback(null);
     }
 }


 string GetAuthURL()
 {
     //date参数生成
     string date = DateTime.UtcNow.ToString("r");

     //authorization参数生成
     StringBuilder stringBuilder = new StringBuilder("host: spark-api.xf-yun.com\n");
     stringBuilder.Append("date: ").Append(date).Append("\n");
     stringBuilder.Append("GET /").Append(llmVersion).Append(" HTTP/1.1");

     //利用hmac-sha256算法结合APISecret对上一步的tmp签名,获得签名后的摘要tmp_sha。
     //将上方的tmp_sha进行base64编码生成signature
     string signature = HMACsha256(apiSecret, stringBuilder.ToString());

     //利用上面生成的签名,拼接下方的字符串生成authorization_origin
     string authorization_origin = string.Format("api_key=\"{0}\", algorithm=\"{1}\", headers=\"{2}\", signature=\"{3}\"", apiKey, "hmac-sha256", "host date request-line", signature);

     //最后再将上方的authorization_origin进行base64编码,生成最终的authorization
     string authorization = Convert.ToBase64String(Encoding.UTF8.GetBytes(authorization_origin));

     //将鉴权参数组合成最终的键值对,并urlencode生成最终的握手URL。
     string path1 = "authorization=" + authorization;
     string path2 = "date=" + WebUtility.UrlEncode(date);
     string path3 = "host=" + "spark-api.xf-yun.com";

     return "wss://spark-api.xf-yun.com/" + llmVersion + "?" + path1 + "&" + path2 + "&" + path3;
 }


 public string HMACsha256(string apiSecretIsKey, string buider)
 {
     byte[] bytes = Encoding.UTF8.GetBytes(apiSecretIsKey);
     System.Security.Cryptography.HMACSHA256 hMACSHA256 = new System.Security.Cryptography.HMACSHA256(bytes);
     byte[] date = Encoding.UTF8.GetBytes(buider);
     date = hMACSHA256.ComputeHash(date);
     hMACSHA256.Clear();

     //将上方的tmp_sha进行base64编码生成signature
     return Convert.ToBase64String(date);
 }

DialogueManager

作为UI和AI的中间层,存储历史的用户和AI对话,对历史对话进行裁切,保存到本地。

cs 复制代码
//说话
public void Talk(string contentText, Action<string> completeCallback, Action<string> streamingCallback)
{
    //添加到历史记录
    AddHistory(Role.User, contentText);
    
    //用于发送到AIManager,拼接上玩家的角色设定
    List<Content> dialogue = new List<Content>() { CharacterSettingContent };
    dialogue.AddRange(historyDialogue);

    //日志打印
    StringBuilder sb = new StringBuilder();
    foreach(Content content in dialogue)
    {
        sb.Append(content.content).Append("\n");
    }
    Debug.LogWarning(sb.ToString());

    //发送给AI
    AIManager.Instance.RequestAnswer(dialogue, (answer) =>
    {
        if(answer != null)
            AddHistory(Role.AI, answer);

        completeCallback(answer);
    }, streamingCallback);
}

public void AddHistory(Content content)
{
    Role role = Role.User;
    switch(content.role)
    {
    case "system":
        role = Role.System;
        break;
    case "user":
        role = Role.User;
        break;
    case "assistant":
        role = Role.AI;
        break;
    }

    AddHistory(role, content.content);
}

public void AddHistory(Role role, string contentText)
{
    //确定当前加入历史记录的Role
    string roleStr = "";
    switch(role)
    {
    case Role.System:
        roleStr = "system";
        break;
    case Role.User:
        roleStr = "user";
        break;
    case Role.AI:
        roleStr = "assistant";
        break;
    default:
        break;
    }

    //添加到历史记录
    historyDialogue.AddLast(new Content() { role = roleStr, content = contentText });
    currentTokens += contentText.Length;

    //如果是用户发送的,就进行裁切
    if(role == Role.User)
    {
        //裁切历史文本
        while (currentTokens >= MaxHistoryTokens)
        {
            //发送删除委托
            OnDialogueRemoved?.Invoke(historyDialogue.First());

            currentTokens -= historyDialogue.First.Value.content.Length;
            historyDialogue.RemoveFirst();
        }
    }

    //序列化后保存到本地
    SaveManager.Instance.data.dialogues = historyDialogue;
    SaveManager.Instance.Save();

    StringBuilder sb = new StringBuilder();
    foreach (Content content in historyDialogue)
    {
        sb.Append(content.content).Append("\n");
    }
    Debug.Log(sb.ToString());

    //发送委托
    OnDialogueAdded?.Invoke(historyDialogue.Last());
}

UIManager

负责跨UI的交互,如输入发送用户文本,需要触发接收显示对话气泡文本,涉及到两个UI类。配合DOTween实现打字机效果,无代码实现随字体伸展的对话框效果。

下面是对话气泡的显示效果

cs 复制代码
using DG.Tweening;
using DG.Tweening.Core;
using DG.Tweening.Plugins.Options;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class DialogueBubbleUI : MonoBehaviour
{
    //UI引用
    public TMP_Text shortTextUI, longTextUI;
    public GameObject shortUI, longUI;
    private ScrollRect longUIScrollRect;

    //分割属性
    public int splitLength = 360;
    public int limitShortTextUIHeigt = 171;

    //打字机效果
    TweenerCore<string, string, StringOptions> tweener;
    public float typeSpeed = .01f;

    //临时存储
    private string lastTextContent = "";

    private void Awake()
    {
        longUIScrollRect = longUI.GetComponent<ScrollRect>();
    }

    //隐藏和复位所有对话框
    public void ResetUI()
    {
        shortUI.SetActive(false);
        shortTextUI.text = string.Empty;
        longUI.SetActive(false);
        longTextUI.text = string.Empty;
    }

    public void PlayFromStart(string endText)
    {
        ResetUI();

        Play(endText);
    }

    public void Play(string endText)
    {
        string startText;
        if (longTextUI.text != string.Empty)
            startText = longTextUI.text;
        else
            startText = shortTextUI.text;

        //DoTween实现打字机效果
        tweener?.Kill();
        tweener = DOTween.To(() => startText, value =>
        {
            //中间判断说的话的长度,确定是否需要转到大的对话框(可下拉的那种)
            //达到阈值后转为显示大对话框
            if (shortTextUI.rectTransform.rect.height > limitShortTextUIHeigt && shortTextUI.text.Length > 0)
            {
                if (!longUI.activeSelf)
                {
                    shortUI.SetActive(false);
                    longUI.SetActive(true);
                }

                longTextUI.text = value;
                longUIScrollRect.verticalNormalizedPosition = 0f;
            }
            else
            {
                if (!shortUI.activeSelf)
                {
                    shortUI.SetActive(true);
                    longUI.SetActive(false);
                }

                shortTextUI.text = value;
            }

            //播放UI音效
            if (lastTextContent != value)
                AudioManager.Instance.PlayUIAudio(UIAudioType.DialoguePopText);
            lastTextContent = value;

        }, endText, endText.Length * typeSpeed).SetUpdate(true).SetEase(Ease.Linear);
    }
}

ModelManager

负责触发模型的动画,使用枚举值选择动画,枚举名称对应了animator中的对应名称的参数。

负责定时随机播放模型的待机动画,问问题时的思考动画播放

AudioManager

负责播放UI音效(文字冒泡音效,),内部使用池化的思想,构建了一个大小固定的AudioSource池,对外暴露的Play接口会从池中获取闲置的或者最早使用的AudioSource,避免每次都重新创建该组件。

使用AudioMixer配合暴露的参数来统一控制播放的音量。

负责背景音乐的播放,开始时随机选择一首,播放完一首后播放下一首。对外暴露接口供播放器UI调用。

cs 复制代码
using DG.Tweening;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Audio;

public class AudioManager : UnitySingleton<AudioManager>
{
    [Header("UI Audio")]
    //UI音效对象池
    public int AudioPoolSize = 10;
    private GameObject audioSourceParent;
    private Queue<AudioSource> audioSources;

    //UI音量组
    public AudioMixerGroup uiAudioMixerGroup;
    private float initUIAudioVolume;

    //配置UI音频列表
    [System.Serializable]
    public class UIAudioClipConfig
    {
        public UIAudioType type;
        public AudioClip clip;
    }
    public List<UIAudioClipConfig> uiAudioClips;
    private Dictionary<UIAudioType, AudioClip> uiAudioClipsDict = new Dictionary<UIAudioType, AudioClip>();


    [Header("BGM")]
    //背景音量组(背景使用单独的AudioSource)
    public AudioMixerGroup bgmAudioMixerGroup;
    private float initBGMAudioVolume;
    private AudioSource bgmAudioSource;

    //配置背景音频列表
    [System.Serializable]
    public class BGAudioClipConfig
    {
        public string SongTitle, Singer;
        public AudioClip song;
    }
    public List<BGAudioClipConfig> BGMs;
    private int currentBgmIndex;
    private bool isMusicPlay = true;
    

    //委托
    public event System.Action<BGAudioClipConfig> OnBGMSelected;
    public event System.Action OnBGMPlay, OnBGMPause;


    protected override void Awake()
    {
        base.Awake();

        //初始化默认参数
        uiAudioMixerGroup.audioMixer.GetFloat("UIVolume", out initUIAudioVolume);
        bgmAudioMixerGroup.audioMixer.GetFloat("BGMVolume", out initBGMAudioVolume);

        //编辑器面板配置的UI参数数组转为字典
        foreach(var config in  uiAudioClips)
        {
            uiAudioClipsDict.Add(config.type, config.clip);
        }
        uiAudioClips.Clear();

        //初始化一个AudioSources子级对象
        audioSourceParent = transform.Find("AudioSources")?.gameObject;
        if (audioSourceParent == null)
        {
            GameObject obj = new GameObject("AudioSources");
            audioSourceParent = obj;
            audioSourceParent.transform.parent = transform;
        }

        //在子级对象上添加UIAudioSource
        audioSources = new Queue<AudioSource>(AudioPoolSize);
        for (int i = 0; i < AudioPoolSize; i++)
        {
            audioSources.Enqueue(audioSourceParent.AddComponent<AudioSource>());
        }

        //在子级上添加背景的AudioSource
        bgmAudioSource = audioSourceParent.AddComponent<AudioSource>();
        bgmAudioSource.outputAudioMixerGroup = bgmAudioMixerGroup;
    }

    private void Start()
    {
        //读取UI音频设置
        SetUIAudioVolume(SaveManager.Instance.data.uiVolumeMultiper);
        //读取背景音频设置
        SetBGMAudioVolume(SaveManager.Instance.data.bgmVolumeMultiper);

        //读取播放设置
        isMusicPlay = SaveManager.Instance.data.isMusicPlay;

        //随机选择背景音乐
        SelectBGM(Random.Range(0, BGMs.Count));
    }

    //设置UI音量
    public void SetUIAudioVolume(float multiper)
    {
        //修改AudioMixerGroup
        uiAudioMixerGroup.audioMixer.SetFloat("UIVolume", multiper < 0.05 ? -1000 : Mathf.LerpUnclamped(initUIAudioVolume * 2, initUIAudioVolume, multiper));

        //存储到本地
        SaveManager.Instance.data.uiVolumeMultiper = multiper;
        SaveManager.Instance.Save();
    }

    //设置BGM音量
    public void SetBGMAudioVolume(float multiper)
    {
        //修改AudioMixerGroup
        bgmAudioMixerGroup.audioMixer.SetFloat("BGMVolume", multiper < 0.05 ? -1000 : Mathf.LerpUnclamped(initBGMAudioVolume * 2, initBGMAudioVolume, multiper));

        //存储到本地
        SaveManager.Instance.data.bgmVolumeMultiper = multiper;
        SaveManager.Instance.Save();
    }


    public void PlayAudio(AudioClip audioClip, AudioSource audioSource)
    {
        audioSource.PlayOneShot(audioClip);
        audioSources.Enqueue(audioSource);
    }

    public void PlayUIAudio(UIAudioType audioType)
    {
        AudioSource audioSource = audioSources.Dequeue();
        audioSource.outputAudioMixerGroup = uiAudioMixerGroup;
        PlayAudio(uiAudioClipsDict[audioType], audioSource);
    }

    private void Update()
    {
        if(isMusicPlay)
        {
            if(!bgmAudioSource.isPlaying)
            {
                SelectNextBGM();
            }
        }
    }

    //BGM
    public void PlayBGM()
    {
        isMusicPlay = true;

        bgmAudioSource.clip = BGMs[currentBgmIndex].song;
        bgmAudioSource.Play();

        SaveManager.Instance.data.isMusicPlay = true;
        SaveManager.Instance.Save();

        OnBGMPlay?.Invoke();
    }

    public void PauseBGM()
    {
        isMusicPlay = false;

        bgmAudioSource.Pause();

        SaveManager.Instance.data.isMusicPlay = false;
        SaveManager.Instance.Save();

        OnBGMPause?.Invoke();
    }

    public void SelectBGM(int index)
    {
        if (index >= BGMs.Count)
            return;

        currentBgmIndex = index;
        OnBGMSelected?.Invoke(BGMs[index]);

        if (isMusicPlay)
            PlayBGM();
        else
            PauseBGM();
    }

    public void SelectLastBGM() => SelectBGM((currentBgmIndex - 1) % BGMs.Count);
    public void SelectNextBGM() => SelectBGM((currentBgmIndex + 1) % BGMs.Count);
}

public enum UIAudioType
{
    DialoguePopText,
    SendBtn,
    ButtonPress
}

SaveManager

内部有一个Data可序列化类。外部可修改该类内部数据,并调用Save方法将其序列化到本地。

当前序列化的内容有历史对话,音量调整,大模型设置,光线调整。

cs 复制代码
protected override void Awake()
{
    base.Awake();

    //获取文件,读取所需的Json文件并序列化为Data
    dataPath = Application.persistentDataPath + "/" + dataName;
    if(File.Exists(dataPath))
    {
        using(StreamReader sr = new StreamReader(dataPath))
        {
            string json = sr.ReadToEnd();
            data = JsonConvert.DeserializeObject<Data>(json);
        }
    }
    else
    {
        data = new Data();
    }
}

public void Save()
{
    if (File.Exists(dataPath))
        File.Delete(dataPath);

    string json = JsonConvert.SerializeObject(data);
    File.WriteAllText(dataPath, json);
}

详细部分

AI

对外暴露最大回复数,模型的切换,内部根据官方文档实现了鉴权和websocket连接服务器。以及流式返回和最终返回的委托。

UI

通过Content Size Filter和Vertical Layout Group 和 Horizontal Layout Group 的多层嵌套,以及两种版本的气泡UI,通过同一个类对其进行显隐和更新的控制,实现了较少字体时气泡随字体更新改变自身大小,较多字体时气泡固定并允许内容滚动显示的功能。对外只需要调用Play并传入文字即可。

动画

使用Unity的Avatar动画重定向功能,将Mixamo骨骼动画映射到MMD骨骼上。

音频

使用枚举和字典来让外部确认要播放什么音频,通过在AudioManager下配置枚举值对应音频,再向外提供通过枚举值进行播放的接口

背景音乐通过将音频和音乐名,歌手名整合成一个类,更改时使用委托将组合类发送出去。UI可以根据接受到的类显示歌和歌手名

相关推荐
WeeJot嵌入式几秒前
卷积神经网络:深度学习中的图像识别利器
人工智能
蝶开三月2 分钟前
php:使用socket函数创建WebSocket服务
网络·websocket·网络协议·php·socket
脆皮泡泡9 分钟前
Ultiverse 和web3新玩法?AI和GameFi的结合是怎样
人工智能·web3
机器人虎哥13 分钟前
【8210A-TX2】Ubuntu18.04 + ROS_ Melodic + TM-16多线激光 雷达评测
人工智能·机器学习
码银20 分钟前
冲破AI 浪潮冲击下的 迷茫与焦虑
人工智能
何大春24 分钟前
【弱监督语义分割】Self-supervised Image-specific Prototype Exploration for WSSS 论文阅读
论文阅读·人工智能·python·深度学习·论文笔记·原型模式
uncle_ll32 分钟前
PyTorch图像预处理:计算均值和方差以实现标准化
图像处理·人工智能·pytorch·均值算法·标准化
宋1381027972032 分钟前
Manus Xsens Metagloves虚拟现实手套
人工智能·机器人·vr·动作捕捉
SEVEN-YEARS36 分钟前
深入理解TensorFlow中的形状处理函数
人工智能·python·tensorflow
世优科技虚拟人39 分钟前
AI、VR与空间计算:教育和文旅领域的数字转型力量
人工智能·vr·空间计算