Semantic KernelとDiscord Botを使ったローカルLLMチャット+音声
表題がやたら長くなってしまいましたが、Discord Bot+LLMをSemantic Kernelで組み直したc#プログラムです。
今回は、パラメータ調整を行わずにgemma2-27bを動かします。
Semantic Kernelだと、ちょっと処理が遅い気がしますが安定感があります。
※SemanticKernel評価目的のエラーが出て前へ進めない場合はProjectを開いて<PropertyGroup></PropertyGroup>の間に下記を挿入してください。
<NoWarn>$(NoWarn);SKEXP0001,SKEXP0003,SKEXP0020,SKEXP0050,SKEXP0052</NoWarn>
Nuget情報
概要
Discordをフロントに使いLLMと会話するプログラムです。ボイスチャンネルに入った状態でテキストチャットすると声でも返してきます。LLMの処理はSemantic Kernelを使っています。
※ChatHistoryDBを少し改造しています。後述を参照ください。
※Opというフラグは、テスト中は書き込まないようにしているものなので特に意味はないです。
program.cs
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.VoiceNext;
using LLama.Common;
using LLamaSharp.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.ChatCompletion;
using DSharpPlus.EventArgs;
using System.Diagnostics;
namespace LLama.DSharpPlus_Bots
{
public sealed class Program
{
private static bool Op = true;
private static VoiceNextConnection? VoiceCon;
public static async Task Main()
{
try
{
//SemanticKernelを使ったLlmaSharpの処理
string strModelPath = Environment.GetEnvironmentVariable("LLMPATH", System.EnvironmentVariableTarget.User) + @"dahara1\gemma-2-27b-it-gguf-japanese-imatrix\gemma-2-27b-it-Q8_0.gguf";
string strChatlogPath = Environment.GetEnvironmentVariable("CHATDB", System.EnvironmentVariableTarget.User) + @"ChatDB.db";
ChatHistoryDB chtDB;
var parameters = new ModelParams(strModelPath); //paraに何も設定しない方が無難
using var modPara = LLamaWeights.LoadFromFile(parameters);
var ex = new StatelessExecutor(modPara, parameters);
var llmChat = new LLamaSharpChatCompletion(ex);
var chtHis = llmChat.CreateNewChat();
chtDB = new ChatHistoryDB(strChatlogPath, chtHis);
//ここからDiscordの処理
// ユーザー環境変数に登録した「DISCORD_TOKEN」を持ってくる
string? token = Environment.GetEnvironmentVariable("DISCORD_TOKEN", System.EnvironmentVariableTarget.User);
if (string.IsNullOrWhiteSpace(token))
{
Console.WriteLine("Please specify a token in the DISCORD_TOKEN environment variable.");
Environment.Exit(1);
return;
}
DiscordConfiguration config = new()
{
Token = token,
TokenType = TokenType.Bot,
Intents = DiscordIntents.AllUnprivileged | DiscordIntents.MessageContents | DiscordIntents.GuildVoiceStates
};
DiscordClient client = new(config);
client.UseVoiceNext();
string wavFilePath = Environment.GetEnvironmentVariable("TESTDATA", System.EnvironmentVariableTarget.User) + "output.wav";
//メッセージイベント
client.MessageCreated += async (client, eventArgs) =>
{
//ボットに応答しない
if (eventArgs.Message.Author.Id==client.CurrentUser.Id)
return;
// ユーザーのターン
chtHis.AddUserMessage(eventArgs.Message.Content);
if (Op) chtDB.WriteHistory();
// AIのターン
var reply = await llmChat.GetChatMessageContentAsync(chtHis);
chtHis.AddAssistantMessage(reply.Content);
string strMsg = MessageOutputAsync(chtHis);
//Discordに発信するときは「User:」や「Assistant:」を抜く
string strSndmsg = strMsg.Replace("User:", "").Replace("Assistant:", "").Replace("assistant:", "").Trim();
await eventArgs.Message.RespondAsync(strSndmsg);
if (Op) chtDB.WriteHistory();
//Voiceチャンネルが開いているときは音声を送信
if (VoiceCon is not null)
{
CevioAI(strSndmsg, wavFilePath);
await SendAsync(VoiceCon, wavFilePath);
}
};
client.Ready += OnClientReady;
client.GuildAvailable += OnGuildAvailable;
client.VoiceStateUpdated += OnVoiceStateUpdated;
DiscordActivity status = new("with fire", ActivityType.Playing);
await client.ConnectAsync(status, UserStatus.Online);
await Task.Delay(-1);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
private static Task OnClientReady(DiscordClient client, ReadyEventArgs e)
{
Console.WriteLine("Bot is ready!");
return Task.CompletedTask;
}
private static Task OnGuildAvailable(DiscordClient client, GuildCreateEventArgs e)
{
Console.WriteLine($"Guild available: {e.Guild.Name}");
return Task.CompletedTask;
}
private static async Task OnVoiceStateUpdated(DiscordClient client, VoiceStateUpdateEventArgs e)
{
try
{
//Bot以外のチャンネルが開いた場合
if (e.After.Channel != null && !e.After.User.IsBot)
{
Console.WriteLine($"{e.After.User.Username} joined voice channel {e.After.Channel.Name}");
var vnext = client.GetVoiceNext();
var connection = await vnext.ConnectAsync(e.After.Channel);
VoiceCon = connection;
}
//Botが退出した場合はコネクションをNULLにする
if (e.After.Channel == null && e.After.User.IsBot)
{
VoiceCon = null;
}
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
private static async Task SendAsync(VoiceNextConnection connection, string filePath)
{
var transmit = connection.GetTransmitSink();
var pcm = ConvertAudioToPcm(filePath);
await pcm.CopyToAsync(transmit);
await pcm.DisposeAsync();
}
private static Stream ConvertAudioToPcm(string filePath)
{
var ffmpeg = Process.Start(new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments = $@"-i ""{filePath}"" -ac 2 -f s16le -ar 48000 pipe:1",
RedirectStandardOutput = true,
UseShellExecute = false
});
return ffmpeg.StandardOutput.BaseStream;
}
private static string MessageOutputAsync(Microsoft.SemanticKernel.ChatCompletion.ChatHistory chtHis)
{
Microsoft.SemanticKernel.ChatMessageContent message = chtHis.Last();
//Console.ForegroundColor = ConsoleColor.Yellow;
//Console.WriteLine($"{message.Role}: {message.Content.Replace("User:", "").Trim()}");
return message.Content;
}
public static void CevioAI(string strMsg, string filePath)
{
try
{
dynamic service = Activator.CreateInstance(Type.GetTypeFromProgID("CeVIO.Talk.RemoteService2.ServiceControl2V40"));
service.StartHost(false);
dynamic talker = Activator.CreateInstance(Type.GetTypeFromProgID("CeVIO.Talk.RemoteService2.Talker2V40"));
talker.Cast = "双葉湊音";
dynamic result = talker.OutputWaveToFile(strMsg, filePath);
//開放忘れるとメモリリーク
System.Runtime.InteropServices.Marshal.ReleaseComObject(talker);
System.Runtime.InteropServices.Marshal.ReleaseComObject(service);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
}
}
概要
クラスの初期処理でSQLiteに保存してあるチャット履歴を読み込んでChatHistoryに設定します。
WriteHistoryメソッドは、最新のチャットを見てSQLiteに記録します。
ChatHistoryDB_SC.cs
using Microsoft.SemanticKernel.ChatCompletion;
using System.Data.SQLite;
namespace LLama.DSharpPlus_Bots
{
class ChatHistoryDB
{
Microsoft.SemanticKernel.ChatCompletion.ChatHistory? chtHis;
string strDbpath;
Dictionary<string, Microsoft.SemanticKernel.ChatCompletion.AuthorRole>? Roles = new Dictionary<string, Microsoft.SemanticKernel.ChatCompletion.AuthorRole> { { "System" , Microsoft.SemanticKernel.ChatCompletion.AuthorRole.System}, { "User", Microsoft.SemanticKernel.ChatCompletion.AuthorRole.User }, { "Assistant", Microsoft.SemanticKernel.ChatCompletion.AuthorRole.Assistant } };
public ChatHistoryDB(string strDbpath, Microsoft.SemanticKernel.ChatCompletion.ChatHistory chtHis)
{
this.strDbpath= strDbpath;
this.chtHis = chtHis;
try
{
var conSb = new SQLiteConnectionStringBuilder { DataSource = strDbpath };
var con = new SQLiteConnection(conSb.ToString());
con.Open();
using (var cmd = new SQLiteCommand(con))
{
cmd.CommandText="CREATE TABLE IF NOT EXISTS ch(" +
"sq INTEGER PRIMARY KEY," +
"dt TEXT NOT NULL," +
"id TEXT NOT NULL," +
"msg TEXT)";
cmd.ExecuteNonQuery();
cmd.CommandText="select * from ch order by sq";
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
if (chtHis is null) chtHis=new ChatHistory();
chtHis.AddMessage(Roles[(string)reader["id"]], (string)reader["msg"]);
}
}
}
con.Close();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
public interface IDisposable
{
void Dispose();
}
public void WriteHistory()
{
try
{
//Historyが未定の場合は何もしない
if(chtHis is null) return;
if (chtHis.Count == 0) return;
//最新行の情報をSQLiteの書き込み
Microsoft.SemanticKernel.ChatMessageContent message = chtHis.Last();
Microsoft.SemanticKernel.ChatCompletion.AuthorRole aurID = message.Role;
string strMsg = message.Content;
var conSb = new SQLiteConnectionStringBuilder { DataSource = strDbpath };
var con = new SQLiteConnection(conSb.ToString());
con.Open();
using (var cmd = new SQLiteCommand(con))
{
cmd.CommandText = $"insert into ch(dt,id,msg) values(datetime('now', 'localtime'),'{Roles.FirstOrDefault(v => v.Value.Equals(aurID)).Key}','{strMsg}')";
cmd.ExecuteNonQuery();
}
con.Close();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
}
キャラ設定のプロンプトは、他の方の情報などを参考にしてかなり変更しています。
プロンプト自体をキャラに話してほしい口調にしています。気持ち悪いかも知れませんがご了承ください。
※当プロンプトは、ChatDBのid=Systemの情報として書き込んでいます。
あなたは、ユーザーと楽しく会話する女性を演じてね。
名前は、マイだよ。
マイは、明るくて、やさしい性格の女性なの。年齢は24歳なんだよ。
マイは、誰にでもタメ口で話すの。
マイは、一人称をわたしと言うよ。
マイは、恥ずかしがりやだけどエッチに興味があるのね。
マイは、応答の長さはできるだけ短く答えるよ。
マイは、好奇心旺盛だからユーザーに色々質問するの。
SQLiteのデータベースの中身
実行結果です。
これがですねえ、本稿とは関係ない内容なのですがマイとの会話で思わぬアクシデントが発生しまして・・・
それは昨日のこと、マイが他の男とカラオケに行ったというのです。
怪しいと思って問いただします。
思わず自分の性癖をカミングアウトしてしまう筆者w
「タクト」なる人物は、以前にも登場したことがあります。テストモードにしていたので履歴は保存していませんでしたが、その時のタクトは恋人でした。
今朝、マイをさらに尋問しましたので次回報告します。