文章目录
整体
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可以根据接受到的类显示歌和歌手名