From 8dbf5d399b9851ec9885b2cf02dbac8df86d99bd Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 4 Oct 2024 12:50:17 +0200 Subject: [PATCH 01/51] LlmEventProcessor to handling streaming of messages --- ChatRPG/API/LlmEventProcessor.cs | 78 ++++++++++++++++++++++++++ ChatRPG/API/ReActLlmClient.cs | 94 ++++++++++++++++++++++++++++++++ ChatRPG/ChatRPG.csproj | 1 + 3 files changed, 173 insertions(+) create mode 100644 ChatRPG/API/LlmEventProcessor.cs create mode 100644 ChatRPG/API/ReActLlmClient.cs diff --git a/ChatRPG/API/LlmEventProcessor.cs b/ChatRPG/API/LlmEventProcessor.cs new file mode 100644 index 0000000..2b78ba0 --- /dev/null +++ b/ChatRPG/API/LlmEventProcessor.cs @@ -0,0 +1,78 @@ +using System.Text; +using System.Threading.Channels; +using LangChain.Providers; +using LangChain.Providers.OpenAI; + +namespace ChatRPG.API; + +public class LlmEventProcessor +{ + private readonly object _lock = new object(); + private readonly StringBuilder _buffer = new StringBuilder(); + private bool _foundFinalAnswer = false; + private readonly Channel _channel = Channel.CreateUnbounded(); + + public LlmEventProcessor(OpenAiChatModel model) + { + model.DeltaReceived += OnDeltaReceived; + model.ResponseReceived += OnResponseReceived; + } + + public async IAsyncEnumerable GetContentStreamAsync() + { + while (await _channel.Reader.WaitToReadAsync()) + { + while (_channel.Reader.TryRead(out var chunk)) + { + yield return chunk; + } + } + } + + private void OnDeltaReceived(object? sender, ChatResponseDelta delta) + { + lock (_lock) + { + if (_foundFinalAnswer) + { + // Directly output content after "Final answer: " has been detected + _channel.Writer.TryWrite(delta.Content); + } + else + { + // Accumulate the content in the buffer + _buffer.Append(delta.Content); + + // Check if the buffer contains "Final answer: " + var bufferString = _buffer.ToString(); + int finalAnswerIndex = bufferString.IndexOf("Final Answer: ", StringComparison.Ordinal); + + if (finalAnswerIndex != -1) + { + // Output everything after "Final answer: " has been detected + int startOutputIndex = finalAnswerIndex + "Final Answer: ".Length; + + // Switch to streaming mode + _foundFinalAnswer = true; + + // Output any content after "Final answer: " + _channel.Writer.TryWrite(bufferString[startOutputIndex..]); + + // Clear the buffer since it's no longer needed + _buffer.Clear(); + } + } + } + } + + private void OnResponseReceived(object? sender, ChatResponse response) + { + lock (_lock) + { + // Reset the state so that the process can start over + _foundFinalAnswer = false; + _channel.Writer.TryComplete(); + _buffer.Clear(); // Clear buffer to avoid carrying over any previous data + } + } +} diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs new file mode 100644 index 0000000..571ec84 --- /dev/null +++ b/ChatRPG/API/ReActLlmClient.cs @@ -0,0 +1,94 @@ +using LangChain.Chains.HelperChains; +using LangChain.Chains.StackableChains.Agents; +using LangChain.Memory; +using LangChain.Prompts; +using LangChain.Providers.OpenAI; +using LangChain.Providers.OpenAI.Predefined; +using static LangChain.Chains.Chain; + +namespace ChatRPG.API; + +public class ReActLlmClient : IOpenAiLlmClient +{ + private readonly Gpt4Model _llm; + private readonly ConversationBufferMemory _memory; + private readonly PromptTemplate _promptTemplate; + private StackChain _chain; + private readonly ReActAgentExecutorChain _agent; + + public ReActLlmClient(IConfiguration configuration) + { + ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")); + var provider = new OpenAiProvider(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")!); + _llm = new Gpt4Model(provider); + _memory = new ConversationBufferMemory(new ChatMessageHistory()); + _chain = LoadMemory(_memory, outputKey: "history") | Template("I'm AI, hello") | LLM(_llm) | + UpdateMemory(_memory, requestKey: "input", responseKey: "text"); + _promptTemplate = GetTemplate(); + _agent = ReActAgentExecutor(_llm); + //agent.UseTool(); + _llm.Settings = new OpenAiChatSettings { UseStreaming = true }; + + } + + public async Task GetChatCompletion(IList inputs, string systemPrompt) + { + var chain = Set() | _agent; + return (await chain.RunAsync("text"))!; + } + + public IAsyncEnumerable GetStreamedChatCompletion(IList inputs, string systemPrompt) + { + var eventProcessor = new LlmEventProcessor(_llm); + + var chain = Set() | _agent; + + _ = Task.Run(async () => await chain.RunAsync()); + + return eventProcessor.GetContentStreamAsync(); + } + + private PromptTemplate GetTemplate() + { + return new PromptTemplate(new PromptTemplateInput( + template: @"Assistant is a large language model trained by OpenAI. + +Assistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. + +Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics. + +Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. + +TOOLS: +------ + +Assistant has access to the following tools: + +{tools} + +To use a tool, please use the following format: + +``` +Thought: Do I need to use a tool? Yes +Action: the action to take, should be one of [{tool_names}] +Action Input: the input to the action +Observation: the result of the action +``` + +When you have a response to say to the Human, or if you do not need to use a tool, you MUST use the format: + +``` +Thought: Do I need to use a tool? No +Final Answer: [your response here] +``` + +Begin! + +Previous conversation history: +{chat_history} + +New input: {input} +{agent_scratchpad}", + inputVariables: new[] { "tools", "tool_names", "chat_history", "input", "agent_scratchpad" })); + } +} diff --git a/ChatRPG/ChatRPG.csproj b/ChatRPG/ChatRPG.csproj index 6dd62e2..9caddde 100644 --- a/ChatRPG/ChatRPG.csproj +++ b/ChatRPG/ChatRPG.csproj @@ -9,6 +9,7 @@ + From 3165a87a2450b254e4baab9777120cba7fcfd258 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 4 Oct 2024 13:30:43 +0200 Subject: [PATCH 02/51] Change Assistant tag to GM in UI --- ChatRPG/API/Memory/ChatRPGConversationMemory.cs | 14 ++++++++++++++ ChatRPG/API/ReActLlmClient.cs | 1 + ChatRPG/Pages/OpenAiGptMessageComponent.razor | 3 +-- ChatRPG/wwwroot/css/site.css | 4 ++-- 4 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 ChatRPG/API/Memory/ChatRPGConversationMemory.cs diff --git a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs new file mode 100644 index 0000000..5e7f828 --- /dev/null +++ b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs @@ -0,0 +1,14 @@ +using LangChain.Memory; +using LangChain.Schema; + +namespace ChatRPG.API.Memory; + +public class ChatRPGConversationMemory : BaseChatMemory +{ + public override OutputValues LoadMemoryVariables(InputValues? inputValues) + { + throw new NotImplementedException(); + } + + public override List MemoryVariables { get; } +} diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 571ec84..a2e9c11 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -35,6 +35,7 @@ public async Task GetChatCompletion(IList inputs, stri { var chain = Set() | _agent; return (await chain.RunAsync("text"))!; + throw new NotImplementedException(); } public IAsyncEnumerable GetStreamedChatCompletion(IList inputs, string systemPrompt) diff --git a/ChatRPG/Pages/OpenAiGptMessageComponent.razor b/ChatRPG/Pages/OpenAiGptMessageComponent.razor index 0c424eb..a6df305 100644 --- a/ChatRPG/Pages/OpenAiGptMessageComponent.razor +++ b/ChatRPG/Pages/OpenAiGptMessageComponent.razor @@ -1,5 +1,4 @@ @using ChatRPG.API -@using OpenAI_API.Chat @using static OpenAI_API.Chat.ChatMessageRole @inherits ComponentBase @@ -12,5 +11,5 @@ public required OpenAiGptMessage Message { get; set; } private string MessagePrefix => Message.Role.Equals(Assistant) - ? "Assistant" : "Player"; + ? "GM" : "Player"; } diff --git a/ChatRPG/wwwroot/css/site.css b/ChatRPG/wwwroot/css/site.css index 2d9bed5..4918332 100644 --- a/ChatRPG/wwwroot/css/site.css +++ b/ChatRPG/wwwroot/css/site.css @@ -114,7 +114,7 @@ a, .btn-link { background-color: rgb(180, 180, 180); } -.message-container.message-container-assistant { +.message-container-gm { background-color: rgb(222, 222, 222); } @@ -126,7 +126,7 @@ a, .btn-link { .message-player { } -.message-assistant { +.message-gm { } .custom-text-field { From 903248ec9040a1b2317544e65bfe97b0b887f955 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 11 Oct 2024 09:10:18 +0200 Subject: [PATCH 03/51] Memory and summarization --- .../API/Memory/ChatRPGConversationMemory.cs | 63 ++++++++++++++++++- ChatRPG/API/Memory/ChatRPGSummarizer.cs | 53 ++++++++++++++++ ChatRPG/Data/Models/Campaign.cs | 1 + ChatRPG/Program.cs | 1 + ChatRPG/Services/EfPersistenceService.cs | 1 + 5 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 ChatRPG/API/Memory/ChatRPGSummarizer.cs diff --git a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs index 5e7f828..040b7ee 100644 --- a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs +++ b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs @@ -1,14 +1,73 @@ +using ChatRPG.Data.Models; +using ChatRPG.Services; using LangChain.Memory; +using LangChain.Providers; using LangChain.Schema; +using Microsoft.AspNetCore.Components; +using Message = LangChain.Providers.Message; +using MessageRole = LangChain.Providers.MessageRole; namespace ChatRPG.API.Memory; public class ChatRPGConversationMemory : BaseChatMemory { + private string SummaryText { get; set; } = string.Empty; + private IChatModel Model { get; } + private Campaign Campaign { get; } + [Inject] private GameStateManager? GameStateManager { get; set; } + + public string MemoryKey { get; set; } = "history"; + public override List MemoryVariables => new List { MemoryKey }; + + public ChatRPGConversationMemory(Campaign campaign, IChatModel model) + { + Campaign = campaign; + Model = model ?? throw new ArgumentNullException(nameof(model)); + } + + public ChatRPGConversationMemory(Campaign campaign, IChatModel model, string savedSummaryText) + { + Campaign = campaign; + Model = model ?? throw new ArgumentNullException(nameof(model)); + SummaryText = savedSummaryText ?? throw new ArgumentNullException(nameof(savedSummaryText)); + } + + public override OutputValues LoadMemoryVariables(InputValues? inputValues) { - throw new NotImplementedException(); + return new OutputValues(new Dictionary { { MemoryKey, SummaryText } }); } - public override List MemoryVariables { get; } + public override async Task SaveContext(InputValues inputValues, OutputValues outputValues) + { + inputValues = inputValues ?? throw new ArgumentNullException(nameof(inputValues)); + outputValues = outputValues ?? throw new ArgumentNullException(nameof(outputValues)); + + var newMessages = new List(); + + // If the InputKey is not specified, there must only be one input value + var inputKey = InputKey ?? inputValues.Value.Keys.Single(); + + var humanMessageContent = inputValues.Value[inputKey].ToString() ?? string.Empty; + newMessages.Add(new Message(humanMessageContent, MessageRole.Human)); + Campaign.Messages.Add(new Data.Models.Message(Campaign, Data.Models.MessageRole.User, humanMessageContent)); + + // If the OutputKey is not specified, there must only be one output value + var outputKey = OutputKey ?? outputValues.Value.Keys.Single(); + + var aiMessageContent = outputValues.Value[outputKey].ToString() ?? string.Empty; + newMessages.Add(new Message(aiMessageContent, MessageRole.Ai)); + Campaign.Messages.Add(new Data.Models.Message(Campaign, Data.Models.MessageRole.Assistant, aiMessageContent)); + + SummaryText = await Model.SummarizeAsync(newMessages, SummaryText).ConfigureAwait(false); + Campaign.GameSummary = SummaryText; + + await GameStateManager!.SaveCurrentState(Campaign); + } + + public override async Task Clear() + { + await base.Clear().ConfigureAwait(false); + SummaryText = string.Empty; + } } diff --git a/ChatRPG/API/Memory/ChatRPGSummarizer.cs b/ChatRPG/API/Memory/ChatRPGSummarizer.cs new file mode 100644 index 0000000..4d2feb6 --- /dev/null +++ b/ChatRPG/API/Memory/ChatRPGSummarizer.cs @@ -0,0 +1,53 @@ +using LangChain.Memory; +using LangChain.Providers; +using static LangChain.Chains.Chain; + +namespace ChatRPG.API.Memory; + +public static class ChatRPGSummarizer +{ + public const string SummaryPrompt = @" +Progressively summarize the lines of conversation provided, adding onto the previous summary returning a new summary. + +EXAMPLE +Current summary: +The human asks what the AI thinks of artificial intelligence.The AI thinks artificial intelligence is a force for good. + +New lines of conversation: +Human: Why do you think artificial intelligence is a force for good? +AI: Because artificial intelligence will help humans reach their full potential. + +New summary: +The human asks what the AI thinks of artificial intelligence. The AI thinks artificial intelligence is a force for good because it will help humans reach their full potential. +END OF EXAMPLE + +Current summary: +{summary} + +New lines of conversation: +{new_lines} + +New summary:"; + + public static async Task SummarizeAsync( + this IChatModel chatModel, + IEnumerable newMessages, + string existingSummary, + MessageFormatter? formatter = null, + CancellationToken cancellationToken = default) + { + formatter ??= new MessageFormatter(); + formatter.HumanPrefix = "Player"; + formatter.AiPrefix = "GM"; + + var newLines = formatter.Format(newMessages); + + var chain = + Set(existingSummary, outputKey: "summary") + | Set(newLines, outputKey: "new_lines") + | Template(SummaryPrompt) + | LLM(chatModel); + + return await chain.RunAsync("text", cancellationToken: cancellationToken).ConfigureAwait(false) ?? string.Empty; + } +} diff --git a/ChatRPG/Data/Models/Campaign.cs b/ChatRPG/Data/Models/Campaign.cs index 462e5fb..5b13c43 100644 --- a/ChatRPG/Data/Models/Campaign.cs +++ b/ChatRPG/Data/Models/Campaign.cs @@ -24,6 +24,7 @@ public Campaign(User user, string title, string startScenario) : this(user, titl public string Title { get; private set; } = null!; public DateTime StartedOn { get; private set; } public ICollection Messages { get; } = new List(); + public string GameSummary { get; set; } = string.Empty; public ICollection Characters { get; } = new List(); public ICollection Environments { get; } = new List(); public Character Player => Characters.First(c => c.IsPlayer); diff --git a/ChatRPG/Program.cs b/ChatRPG/Program.cs index ba3abb8..b5c2007 100644 --- a/ChatRPG/Program.cs +++ b/ChatRPG/Program.cs @@ -1,5 +1,6 @@ using Blazored.Modal; using ChatRPG.API; +using ChatRPG.API.Memory; using ChatRPG.Areas.Identity; using ChatRPG.Data; using ChatRPG.Data.Models; diff --git a/ChatRPG/Services/EfPersistenceService.cs b/ChatRPG/Services/EfPersistenceService.cs index ea60286..abb803f 100644 --- a/ChatRPG/Services/EfPersistenceService.cs +++ b/ChatRPG/Services/EfPersistenceService.cs @@ -74,6 +74,7 @@ public async Task LoadFromCampaignIdAsync(int campaignId) return await _dbContext.Campaigns .Where(campaign => campaign.Id == campaignId) .Include(campaign => campaign.Messages) + .Include(campaign => campaign.GameSummary) .Include(campaign => campaign.Environments) .Include(campaign => campaign.Characters) .ThenInclude(character => character.CharacterAbilities) From 90707d1a2f2e55017f4ace1762d70f6e908ee39f Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 11 Oct 2024 09:47:05 +0200 Subject: [PATCH 04/51] Set to .net8 and upgrade packages --- ChatRPG/ChatRPG.csproj | 30 +++++++++++++++--------------- ChatRPGTests/ChatRPGTests.csproj | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/ChatRPG/ChatRPG.csproj b/ChatRPG/ChatRPG.csproj index 9caddde..ef29f6b 100644 --- a/ChatRPG/ChatRPG.csproj +++ b/ChatRPG/ChatRPG.csproj @@ -1,35 +1,35 @@ - net7.0 + net8.0 enable enable aspnet-ChatRPG-5312bf6c-6a60-492a-bb3a-fb97f9b3103f - + - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - + diff --git a/ChatRPGTests/ChatRPGTests.csproj b/ChatRPGTests/ChatRPGTests.csproj index a723495..c318777 100644 --- a/ChatRPGTests/ChatRPGTests.csproj +++ b/ChatRPGTests/ChatRPGTests.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 enable enable From 8e4ab0f89b5372ec05c5636b7253879b3f422c1e Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 11 Oct 2024 12:03:32 +0200 Subject: [PATCH 05/51] Create custom ReActAgentChain --- ChatRPG/API/Memory/ChatRPGSummarizer.cs | 10 +- ChatRPG/API/ReActAgentChain.cs | 164 ++++++++++++++++++++++++ ChatRPG/API/ReActLlmClient.cs | 2 + 3 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 ChatRPG/API/ReActAgentChain.cs diff --git a/ChatRPG/API/Memory/ChatRPGSummarizer.cs b/ChatRPG/API/Memory/ChatRPGSummarizer.cs index 4d2feb6..160c9b7 100644 --- a/ChatRPG/API/Memory/ChatRPGSummarizer.cs +++ b/ChatRPG/API/Memory/ChatRPGSummarizer.cs @@ -7,18 +7,18 @@ namespace ChatRPG.API.Memory; public static class ChatRPGSummarizer { public const string SummaryPrompt = @" -Progressively summarize the lines of conversation provided, adding onto the previous summary returning a new summary. +Progressively summarize the interaction between the player and the GM. The player describes their actions in response to the game world, and the GM narrates the outcome, revealing the next part of the adventure. Return a new summary based on each exchange. EXAMPLE Current summary: -The human asks what the AI thinks of artificial intelligence.The AI thinks artificial intelligence is a force for good. +The player enters the forest and cautiously looks around. The GM describes towering trees and a narrow path leading deeper into the woods. The player decides to follow the path, staying alert. New lines of conversation: -Human: Why do you think artificial intelligence is a force for good? -AI: Because artificial intelligence will help humans reach their full potential. +Player: I move carefully down the path, keeping an eye out for any hidden dangers. +GM: As you continue, the air grows colder, and you hear rustling in the bushes ahead. Suddenly, a shadowy figure leaps out in front of you. New summary: -The human asks what the AI thinks of artificial intelligence. The AI thinks artificial intelligence is a force for good because it will help humans reach their full potential. +The player enters the forest and follows a narrow path, staying alert. The GM introduces a shadowy figure that appears ahead after rustling is heard in the bushes. END OF EXAMPLE Current summary: diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs new file mode 100644 index 0000000..57f43e8 --- /dev/null +++ b/ChatRPG/API/ReActAgentChain.cs @@ -0,0 +1,164 @@ +using System.Globalization; +using LangChain.Abstractions.Schema; +using LangChain.Chains.HelperChains; +using LangChain.Chains.StackableChains.Agents.Tools; +using LangChain.Chains.StackableChains.ReAct; +using LangChain.Memory; +using LangChain.Providers; +using LangChain.Schema; +using static LangChain.Chains.Chain; + +namespace ChatRPG.API; + +public sealed class ReActAgentChain : BaseStackableChain +{ + private StackChain? _chain; + private bool _useCache; + private Dictionary _tools = new(); + private readonly IChatModel _model; + private readonly string _reActPrompt; + private readonly int _maxActions; + private readonly BaseChatMemory _conversationSummaryMemory; + private string _userInput = string.Empty; + private const string ReActAnswer = "answer"; + private readonly bool _useStreaming; + + public string DefaultPrompt = @"Assistant is a large language model trained by OpenAI. + +Assistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. + +Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics. + +Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. + +TOOLS: +------ + +Assistant has access to the following tools: + +{tools} + +To use a tool, please use the following format: + +``` +Thought: Do I need to use a tool? Yes +Action: the action to take, should be one of [{tool_names}] +Action Input: the input to the action +Observation: the result of the action +``` + +When you have a response to say to the Human, or if you do not need to use a tool, you MUST use the format: + +``` +Thought: Do I need to use a tool? No +Final Answer: [your response here] +``` + +Always add [END] after final answer + +Begin! + +Previous conversation history: +{history} + +New input: {input}"; + + public ReActAgentChain( + IChatModel model, + BaseChatMemory memory, + string? reActPrompt = null, + string inputKey = "input", + string outputKey = "text", + int maxActions = 10, + bool useStreaming = true) + { + _model = model; + _reActPrompt = reActPrompt ?? DefaultPrompt; + _maxActions = maxActions; + + InputKeys = [inputKey]; + OutputKeys = [outputKey]; + + _useStreaming = useStreaming; + _conversationSummaryMemory = memory; + } + + private void InitializeChain() + { + var toolNames = string.Join(",", _tools.Select(x => x.Key)); + var tools = string.Join("\n", _tools.Select(x => $"{x.Value.Name}, {x.Value.Description}")); + + var chain = + Set(() => _userInput, "input") + | Set(tools, "tools") + | Set(toolNames, "tool_names") + | LoadMemory(_conversationSummaryMemory, outputKey: "history") + | Template(_reActPrompt) + | LLM(_model, settings: new ChatSettings + { + StopSequences = ["Observation", "[END]"], + UseStreaming = _useStreaming + }).UseCache(_useCache) + | UpdateMemory(_conversationSummaryMemory, requestKey: "input", responseKey: "text") + | ReActParser(inputKey: "text", outputKey: ReActAnswer); + + _chain = chain; + } + + public ReActAgentChain UseCache(bool enabled = true) + { + _useCache = enabled; + return this; + } + + public ReActAgentChain UseTool(AgentTool tool) + { + tool = tool ?? throw new ArgumentNullException(nameof(tool)); + + _tools.Add(tool.Name, tool); + return this; + } + + protected override async Task InternalCallAsync(IChainValues values, + CancellationToken cancellationToken = new()) + { + values = values ?? throw new ArgumentNullException(nameof(values)); + + var input = (string)values.Value[InputKeys[0]]; + var valuesChain = new ChainValues(); + + _userInput = input; + + if (_chain == null) + { + InitializeChain(); + } + + for (int i = 0; i < _maxActions; i++) + { + var res = await _chain!.CallAsync(valuesChain, cancellationToken: cancellationToken).ConfigureAwait(false); + switch (res.Value[ReActAnswer]) + { + case AgentAction: + { + var action = (AgentAction)res.Value[ReActAnswer]; + var tool = _tools[action.Action.ToLower(CultureInfo.InvariantCulture)]; + var toolRes = await tool.ToolTask(action.ActionInput, cancellationToken).ConfigureAwait(false); + await _conversationSummaryMemory.ChatHistory.AddMessage(new Message("Observation: " + toolRes, MessageRole.System)) + .ConfigureAwait(false); + await _conversationSummaryMemory.ChatHistory.AddMessage(new Message("Thought:", MessageRole.System)) + .ConfigureAwait(false); + break; + } + case AgentFinish: + { + var finish = (AgentFinish)res.Value[ReActAnswer]; + values.Value[OutputKeys[0]] = finish.Output; + return values; + } + } + } + + return values; + } +} diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index a2e9c11..4145cd0 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -29,6 +29,8 @@ public ReActLlmClient(IConfiguration configuration) //agent.UseTool(); _llm.Settings = new OpenAiChatSettings { UseStreaming = true }; + var test = new ReActAgentChain(_llm, _memory); + } public async Task GetChatCompletion(IList inputs, string systemPrompt) From 3df99f3d2f65f04aaa9d98f3fba38d5fd0d75ae6 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 11 Oct 2024 12:12:25 +0200 Subject: [PATCH 06/51] Reformatting and cleanup --- ChatRPG/API/ReActAgentChain.cs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index 57f43e8..ed82754 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -12,16 +12,16 @@ namespace ChatRPG.API; public sealed class ReActAgentChain : BaseStackableChain { - private StackChain? _chain; - private bool _useCache; - private Dictionary _tools = new(); + private const string ReActAnswer = "answer"; + private readonly BaseChatMemory _conversationSummaryMemory; + private readonly int _maxActions; private readonly IChatModel _model; private readonly string _reActPrompt; - private readonly int _maxActions; - private readonly BaseChatMemory _conversationSummaryMemory; - private string _userInput = string.Empty; - private const string ReActAnswer = "answer"; private readonly bool _useStreaming; + private StackChain? _chain; + private readonly Dictionary _tools = new(); + private bool _useCache; + private string _userInput = string.Empty; public string DefaultPrompt = @"Assistant is a large language model trained by OpenAI. @@ -92,15 +92,15 @@ private void InitializeChain() Set(() => _userInput, "input") | Set(tools, "tools") | Set(toolNames, "tool_names") - | LoadMemory(_conversationSummaryMemory, outputKey: "history") + | LoadMemory(_conversationSummaryMemory, "history") | Template(_reActPrompt) | LLM(_model, settings: new ChatSettings { StopSequences = ["Observation", "[END]"], UseStreaming = _useStreaming }).UseCache(_useCache) - | UpdateMemory(_conversationSummaryMemory, requestKey: "input", responseKey: "text") - | ReActParser(inputKey: "text", outputKey: ReActAnswer); + | UpdateMemory(_conversationSummaryMemory, "input", "text") + | ReActParser("text", ReActAnswer); _chain = chain; } @@ -134,7 +134,7 @@ protected override async Task InternalCallAsync(IChainValues value InitializeChain(); } - for (int i = 0; i < _maxActions; i++) + for (var i = 0; i < _maxActions; i++) { var res = await _chain!.CallAsync(valuesChain, cancellationToken: cancellationToken).ConfigureAwait(false); switch (res.Value[ReActAnswer]) @@ -144,7 +144,8 @@ protected override async Task InternalCallAsync(IChainValues value var action = (AgentAction)res.Value[ReActAnswer]; var tool = _tools[action.Action.ToLower(CultureInfo.InvariantCulture)]; var toolRes = await tool.ToolTask(action.ActionInput, cancellationToken).ConfigureAwait(false); - await _conversationSummaryMemory.ChatHistory.AddMessage(new Message("Observation: " + toolRes, MessageRole.System)) + await _conversationSummaryMemory.ChatHistory + .AddMessage(new Message("Observation: " + toolRes, MessageRole.System)) .ConfigureAwait(false); await _conversationSummaryMemory.ChatHistory.AddMessage(new Message("Thought:", MessageRole.System)) .ConfigureAwait(false); From c7965ae7594f365dfe39a492c0ebe99b0dccd889 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 11 Oct 2024 12:16:57 +0200 Subject: [PATCH 07/51] Remove brackets from switch --- ChatRPG/API/ReActAgentChain.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index ed82754..b739b85 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -140,7 +140,6 @@ protected override async Task InternalCallAsync(IChainValues value switch (res.Value[ReActAnswer]) { case AgentAction: - { var action = (AgentAction)res.Value[ReActAnswer]; var tool = _tools[action.Action.ToLower(CultureInfo.InvariantCulture)]; var toolRes = await tool.ToolTask(action.ActionInput, cancellationToken).ConfigureAwait(false); @@ -150,13 +149,10 @@ await _conversationSummaryMemory.ChatHistory await _conversationSummaryMemory.ChatHistory.AddMessage(new Message("Thought:", MessageRole.System)) .ConfigureAwait(false); break; - } case AgentFinish: - { var finish = (AgentFinish)res.Value[ReActAnswer]; values.Value[OutputKeys[0]] = finish.Output; return values; - } } } From 67ac380bb1120b5bf1b2bc2f84ac3697bb4c7ab9 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 11 Oct 2024 13:59:11 +0200 Subject: [PATCH 08/51] Plug in ReAct Agent into GameInputHandler --- ChatRPG/API/IReActLlmClient.cs | 10 ++ .../API/Memory/ChatRPGConversationMemory.cs | 17 +-- ChatRPG/API/OpenAiGptMessage.cs | 14 +-- ChatRPG/API/ReActLlmClient.cs | 87 +++----------- ChatRPG/ChatRPG.csproj | 1 - ChatRPG/Pages/CampaignPage.razor | 5 +- ChatRPG/Pages/CampaignPage.razor.cs | 15 ++- ChatRPG/Pages/OpenAiGptMessageComponent.razor | 2 +- ChatRPG/Program.cs | 2 +- ChatRPG/Services/GameInputHandler.cs | 109 +++++++++++------- ChatRPG/Services/GameStateManager.cs | 60 +--------- ChatRPG/appsettings.json | 1 + ChatRPGTests/GameStateManagerTests.cs | 3 +- 13 files changed, 120 insertions(+), 206 deletions(-) create mode 100644 ChatRPG/API/IReActLlmClient.cs diff --git a/ChatRPG/API/IReActLlmClient.cs b/ChatRPG/API/IReActLlmClient.cs new file mode 100644 index 0000000..d851e34 --- /dev/null +++ b/ChatRPG/API/IReActLlmClient.cs @@ -0,0 +1,10 @@ +using ChatRPG.Data.Models; +using ChatRPG.Pages; + +namespace ChatRPG.API; + +public interface IReActLlmClient +{ + Task GetChatCompletionAsync(Campaign campaign, string actionPrompt, string input); + IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt, string input); +} diff --git a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs index 040b7ee..c458b5f 100644 --- a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs +++ b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs @@ -11,13 +11,12 @@ namespace ChatRPG.API.Memory; public class ChatRPGConversationMemory : BaseChatMemory { - private string SummaryText { get; set; } = string.Empty; private IChatModel Model { get; } private Campaign Campaign { get; } [Inject] private GameStateManager? GameStateManager { get; set; } public string MemoryKey { get; set; } = "history"; - public override List MemoryVariables => new List { MemoryKey }; + public override List MemoryVariables => [MemoryKey]; public ChatRPGConversationMemory(Campaign campaign, IChatModel model) { @@ -25,17 +24,9 @@ public ChatRPGConversationMemory(Campaign campaign, IChatModel model) Model = model ?? throw new ArgumentNullException(nameof(model)); } - public ChatRPGConversationMemory(Campaign campaign, IChatModel model, string savedSummaryText) - { - Campaign = campaign; - Model = model ?? throw new ArgumentNullException(nameof(model)); - SummaryText = savedSummaryText ?? throw new ArgumentNullException(nameof(savedSummaryText)); - } - - public override OutputValues LoadMemoryVariables(InputValues? inputValues) { - return new OutputValues(new Dictionary { { MemoryKey, SummaryText } }); + return new OutputValues(new Dictionary { { MemoryKey, Campaign.GameSummary } }); } public override async Task SaveContext(InputValues inputValues, OutputValues outputValues) @@ -59,8 +50,7 @@ public override async Task SaveContext(InputValues inputValues, OutputValues out newMessages.Add(new Message(aiMessageContent, MessageRole.Ai)); Campaign.Messages.Add(new Data.Models.Message(Campaign, Data.Models.MessageRole.Assistant, aiMessageContent)); - SummaryText = await Model.SummarizeAsync(newMessages, SummaryText).ConfigureAwait(false); - Campaign.GameSummary = SummaryText; + Campaign.GameSummary = await Model.SummarizeAsync(newMessages, Campaign.GameSummary).ConfigureAwait(false); await GameStateManager!.SaveCurrentState(Campaign); } @@ -68,6 +58,5 @@ public override async Task SaveContext(InputValues inputValues, OutputValues out public override async Task Clear() { await base.Clear().ConfigureAwait(false); - SummaryText = string.Empty; } } diff --git a/ChatRPG/API/OpenAiGptMessage.cs b/ChatRPG/API/OpenAiGptMessage.cs index b47cb6e..d375de5 100644 --- a/ChatRPG/API/OpenAiGptMessage.cs +++ b/ChatRPG/API/OpenAiGptMessage.cs @@ -1,4 +1,3 @@ -using OpenAI_API.Chat; using System.Text.Json; using System.Text.RegularExpressions; using ChatRPG.API.Response; @@ -10,27 +9,29 @@ namespace ChatRPG.API; public class OpenAiGptMessage { - public OpenAiGptMessage(ChatMessageRole role, string content) + public OpenAiGptMessage(MessageRole role, string content) { Role = role; Content = content; NarrativePart = ""; UpdateNarrativePart(); - if (!Content.IsNullOrEmpty() && NarrativePart.IsNullOrEmpty() && role.Equals(ChatMessageRole.Assistant)) + if (!Content.IsNullOrEmpty() && NarrativePart.IsNullOrEmpty() && + role.Equals(LangChain.Providers.MessageRole.Ai)) { NarrativePart = Content; } } - public OpenAiGptMessage(ChatMessageRole role, string content, UserPromptType userPromptType) : this(role, content) + public OpenAiGptMessage(MessageRole role, string content, UserPromptType userPromptType) : this(role, content) { UserPromptType = userPromptType; } - public ChatMessageRole Role { get; } + public MessageRole Role { get; } public string Content { get; private set; } public string NarrativePart { get; private set; } public readonly UserPromptType UserPromptType = UserPromptType.Do; + private static readonly Regex NarrativeRegex = new(pattern: "^\\s*{\\s*\"narrative\":\\s*\"([^\"]*)", RegexOptions.IgnoreCase); @@ -67,7 +68,6 @@ private void UpdateNarrativePart() public static OpenAiGptMessage FromMessage(Message message) { - ChatMessageRole role = ChatMessageRole.FromString(message.Role.ToString().ToLower()); - return new OpenAiGptMessage(role, message.Content); + return new OpenAiGptMessage(message.Role, message.Content); } } diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 4145cd0..9992535 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -1,97 +1,46 @@ -using LangChain.Chains.HelperChains; -using LangChain.Chains.StackableChains.Agents; -using LangChain.Memory; -using LangChain.Prompts; +using ChatRPG.API.Memory; +using ChatRPG.Data.Models; +using ChatRPG.Pages; using LangChain.Providers.OpenAI; using LangChain.Providers.OpenAI.Predefined; using static LangChain.Chains.Chain; namespace ChatRPG.API; -public class ReActLlmClient : IOpenAiLlmClient +public class ReActLlmClient : IReActLlmClient { private readonly Gpt4Model _llm; - private readonly ConversationBufferMemory _memory; - private readonly PromptTemplate _promptTemplate; - private StackChain _chain; - private readonly ReActAgentExecutorChain _agent; + private readonly string _reActPrompt; public ReActLlmClient(IConfiguration configuration) { ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")); + ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts")?.GetValue("ReAct")); + _reActPrompt = configuration.GetSection("SystemPrompts")?.GetValue("ReAct")!; var provider = new OpenAiProvider(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")!); _llm = new Gpt4Model(provider); - _memory = new ConversationBufferMemory(new ChatMessageHistory()); - _chain = LoadMemory(_memory, outputKey: "history") | Template("I'm AI, hello") | LLM(_llm) | - UpdateMemory(_memory, requestKey: "input", responseKey: "text"); - _promptTemplate = GetTemplate(); - _agent = ReActAgentExecutor(_llm); - //agent.UseTool(); - _llm.Settings = new OpenAiChatSettings { UseStreaming = true }; - - var test = new ReActAgentChain(_llm, _memory); - } - public async Task GetChatCompletion(IList inputs, string systemPrompt) + public async Task GetChatCompletionAsync(Campaign campaign, string actionPrompt, string input) { - var chain = Set() | _agent; + var memory = new ChatRPGConversationMemory(campaign, _llm); + var agent = new ReActAgentChain(_llm, memory, _reActPrompt, useStreaming: false); + //agent.UseTool(); + + var chain = Set(actionPrompt, "action") | Set(input, "input") | agent; return (await chain.RunAsync("text"))!; - throw new NotImplementedException(); } - public IAsyncEnumerable GetStreamedChatCompletion(IList inputs, string systemPrompt) + public IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt, string input) { var eventProcessor = new LlmEventProcessor(_llm); + var memory = new ChatRPGConversationMemory(campaign, _llm); + var agent = new ReActAgentChain(_llm, memory, _reActPrompt, useStreaming: false); + //agent.UseTool(); - var chain = Set() | _agent; - + var chain = Set(actionPrompt, "action") | Set(input, "input") | agent; _ = Task.Run(async () => await chain.RunAsync()); return eventProcessor.GetContentStreamAsync(); } - - private PromptTemplate GetTemplate() - { - return new PromptTemplate(new PromptTemplateInput( - template: @"Assistant is a large language model trained by OpenAI. - -Assistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. - -Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics. - -Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. - -TOOLS: ------- - -Assistant has access to the following tools: - -{tools} - -To use a tool, please use the following format: - -``` -Thought: Do I need to use a tool? Yes -Action: the action to take, should be one of [{tool_names}] -Action Input: the input to the action -Observation: the result of the action -``` - -When you have a response to say to the Human, or if you do not need to use a tool, you MUST use the format: - -``` -Thought: Do I need to use a tool? No -Final Answer: [your response here] -``` - -Begin! - -Previous conversation history: -{chat_history} - -New input: {input} -{agent_scratchpad}", - inputVariables: new[] { "tools", "tool_names", "chat_history", "input", "agent_scratchpad" })); - } } diff --git a/ChatRPG/ChatRPG.csproj b/ChatRPG/ChatRPG.csproj index ef29f6b..4c5b4c5 100644 --- a/ChatRPG/ChatRPG.csproj +++ b/ChatRPG/ChatRPG.csproj @@ -28,7 +28,6 @@ - diff --git a/ChatRPG/Pages/CampaignPage.razor b/ChatRPG/Pages/CampaignPage.razor index 890cd14..acca1bb 100644 --- a/ChatRPG/Pages/CampaignPage.razor +++ b/ChatRPG/Pages/CampaignPage.razor @@ -1,6 +1,5 @@ @page "/Campaign" @using OpenAiGptMessage = ChatRPG.API.OpenAiGptMessage -@using OpenAI_API.Chat @using ChatRPG.Data.Models Campaign - @_campaign?.Title @@ -55,7 +54,7 @@
- @foreach (OpenAiGptMessage message in _conversation.Where(m => !m.Role.Equals(ChatMessageRole.System))) + @foreach (OpenAiGptMessage message in _conversation.Where(m => !m.Role.Equals(MessageRole.System))) { } @@ -125,6 +124,6 @@
- © 2023 - ChatRPG + © @DateTime.Now.Year - ChatRPG
diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 7328036..3e4e23c 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Web; using Microsoft.JSInterop; -using OpenAI_API.Chat; using Environment = ChatRPG.Data.Models.Environment; using OpenAiGptMessage = ChatRPG.API.OpenAiGptMessage; @@ -114,9 +113,9 @@ private void InitializeCampaign() } _isWaitingForResponse = true; - OpenAiGptMessage message = new(ChatMessageRole.System, content); + OpenAiGptMessage message = new(MessageRole.System, content); _conversation.Add(message); - GameInputHandler?.HandleInitialPrompt(_campaign, _conversation); + GameInputHandler?.HandleInitialPrompt(_campaign, content); UpdateStatsUi(); } @@ -143,13 +142,13 @@ private async Task SendPrompt() } _isWaitingForResponse = true; - OpenAiGptMessage userInput = new(ChatMessageRole.User, _userInput, _activeUserPromptType); + OpenAiGptMessage userInput = new(MessageRole.User, _userInput, _activeUserPromptType); _conversation.Add(userInput); _latestPlayerMessage = userInput; - _userInput = string.Empty; await ScrollToElement(BottomId); - await GameInputHandler!.HandleUserPrompt(_campaign, _conversation); - _conversation.RemoveAll(m => m.Role.Equals(ChatMessageRole.System)); + await GameInputHandler!.HandleUserPrompt(_campaign, _activeUserPromptType, _userInput); + _userInput = string.Empty; + _conversation.RemoveAll(m => m.Role.Equals(MessageRole.System)); UpdateStatsUi(); } @@ -191,7 +190,7 @@ private void OnChatCompletionReceived(object? sender, ChatCompletionReceivedEven /// The arguments for this event, including the text chunk and whether the streaming is done. private void OnChatCompletionChunkReceived(object? sender, ChatCompletionChunkReceivedEventArgs eventArgs) { - OpenAiGptMessage message = _conversation.LastOrDefault(new OpenAiGptMessage(ChatMessageRole.Assistant, "")); + OpenAiGptMessage message = _conversation.LastOrDefault(new OpenAiGptMessage(MessageRole.Assistant, "")); if (eventArgs.IsStreamingDone) { _isWaitingForResponse = false; diff --git a/ChatRPG/Pages/OpenAiGptMessageComponent.razor b/ChatRPG/Pages/OpenAiGptMessageComponent.razor index a6df305..6f4f949 100644 --- a/ChatRPG/Pages/OpenAiGptMessageComponent.razor +++ b/ChatRPG/Pages/OpenAiGptMessageComponent.razor @@ -1,5 +1,5 @@ @using ChatRPG.API -@using static OpenAI_API.Chat.ChatMessageRole +@using static Data.Models.MessageRole @inherits ComponentBase
diff --git a/ChatRPG/Program.cs b/ChatRPG/Program.cs index b5c2007..13e01cf 100644 --- a/ChatRPG/Program.cs +++ b/ChatRPG/Program.cs @@ -30,7 +30,7 @@ builder.Services.AddScoped>() .AddSingleton(httpMessageHandlerFactory) .AddSingleton() - .AddSingleton() + .AddSingleton() .AddTransient() .AddTransient() .AddTransient() diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index bf34e1f..a1a0750 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -3,7 +3,6 @@ using ChatRPG.Data.Models; using ChatRPG.Pages; using ChatRPG.Services.Events; -using OpenAI_API.Chat; using Environment = ChatRPG.Data.Models.Environment; namespace ChatRPG.Services; @@ -11,7 +10,7 @@ namespace ChatRPG.Services; public class GameInputHandler { private readonly ILogger _logger; - private readonly IOpenAiLlmClient _llmClient; + private readonly IReActLlmClient _llmClient; private readonly GameStateManager _gameStateManager; private readonly bool _streamChatCompletions; private readonly Dictionary _systemPrompts = new(); @@ -28,7 +27,8 @@ public class GameInputHandler { CharacterType.Monster, (20, 30) } }; - public GameInputHandler(ILogger logger, IOpenAiLlmClient llmClient, GameStateManager gameStateManager, IConfiguration configuration) + public GameInputHandler(ILogger logger, IReActLlmClient llmClient, + GameStateManager gameStateManager, IConfiguration configuration) { _logger = logger; _llmClient = llmClient; @@ -38,13 +38,15 @@ public GameInputHandler(ILogger logger, IOpenAiLlmClient llmCl { _streamChatCompletions = false; } + IConfigurationSection sysPromptSec = configuration.GetRequiredSection("SystemPrompts"); _systemPrompts.Add(SystemPromptType.Initial, sysPromptSec.GetValue("Initial", "")!); _systemPrompts.Add(SystemPromptType.CombatHitHit, sysPromptSec.GetValue("CombatHitHit", "")!); _systemPrompts.Add(SystemPromptType.CombatHitMiss, sysPromptSec.GetValue("CombatHitMiss", "")!); _systemPrompts.Add(SystemPromptType.CombatMissHit, sysPromptSec.GetValue("CombatMissHit", "")!); _systemPrompts.Add(SystemPromptType.CombatMissMiss, sysPromptSec.GetValue("CombatMissMiss", "")!); - _systemPrompts.Add(SystemPromptType.CombatOpponentDescription, sysPromptSec.GetValue("CombatOpponentDescription", "")!); + _systemPrompts.Add(SystemPromptType.CombatOpponentDescription, + sysPromptSec.GetValue("CombatOpponentDescription", "")!); _systemPrompts.Add(SystemPromptType.HurtOrHeal, sysPromptSec.GetValue("DoActionHurtOrHeal", "")!); _systemPrompts.Add(SystemPromptType.DoAction, sysPromptSec.GetValue("DoAction", "")!); _systemPrompts.Add(SystemPromptType.SayAction, sysPromptSec.GetValue("SayAction", "")!); @@ -66,22 +68,31 @@ private void OnChatCompletionChunkReceived(bool isStreamingDone, string? chunk = ChatCompletionChunkReceived?.Invoke(this, args); } - public async Task HandleUserPrompt(Campaign campaign, IList conversation) + public async Task HandleUserPrompt(Campaign campaign, UserPromptType promptType, string userInput) { - string systemPrompt = await GetRelevantSystemPrompt(campaign, conversation); - await GetResponseAndUpdateState(campaign, conversation, systemPrompt); - _logger.LogInformation("Finished processing prompt."); + switch (promptType) + { + case UserPromptType.Do: + await GetResponseAndUpdateState(campaign, _systemPrompts[SystemPromptType.DoAction], userInput); + break; + case UserPromptType.Say: + await GetResponseAndUpdateState(campaign, _systemPrompts[SystemPromptType.SayAction], userInput); + break; + default: + throw new ArgumentOutOfRangeException(); + } + _logger.LogInformation("Finished processing prompt"); } - public async Task HandleInitialPrompt(Campaign campaign, IList conversation) + public async Task HandleInitialPrompt(Campaign campaign, string initialInput) { - await GetResponseAndUpdateState(campaign, conversation, _systemPrompts[SystemPromptType.Initial]); - _logger.LogInformation("Finished processing prompt."); + await GetResponseAndUpdateState(campaign, _systemPrompts[SystemPromptType.Initial], initialInput); + _logger.LogInformation("Finished processing prompt"); } private async Task GetRelevantSystemPrompt(Campaign campaign, IList conversation) { - UserPromptType userPromptType = conversation.Last(m => m.Role.Equals(ChatMessageRole.User)).UserPromptType; + UserPromptType userPromptType = conversation.Last(m => m.Role.Equals(MessageRole.User)).UserPromptType; switch (userPromptType) { @@ -97,6 +108,7 @@ private async Task GetRelevantSystemPrompt(Campaign campaign, IList GetRelevantSystemPrompt(Campaign campaign, IList conversation) { - OpenAiGptMessage lastUserMessage = conversation.Last(m => m.Role.Equals(ChatMessageRole.User)); - string hurtOrHealString = await _llmClient.GetChatCompletion(new List() { lastUserMessage }, _systemPrompts[SystemPromptType.HurtOrHeal]); + OpenAiGptMessage lastUserMessage = conversation.Last(m => m.Role.Equals(MessageRole.User)); + string hurtOrHealString = await _llmClient.GetChatCompletionAsync(new List() { lastUserMessage }, + _systemPrompts[SystemPromptType.HurtOrHeal]); _logger.LogInformation("Hurt or heal response: {hurtOrHealString}", hurtOrHealString); - OpenAiGptMessage hurtOrHealMessage = new(ChatMessageRole.Assistant, hurtOrHealString); + OpenAiGptMessage hurtOrHealMessage = new(MessageRole.Assistant, hurtOrHealString); LlmResponse? hurtOrHealResponse = hurtOrHealMessage.TryParseFromJson(); string hurtOrHealMessageContent = ""; Random rand = new Random(); @@ -127,7 +140,8 @@ private async Task DetermineAndPerformHurtOrHeal(Campaign campaign, ICollection< { int dmgAmount = rand.Next(PlayerDmgMin, PlayerDmgMax); bool playerDied = campaign.Player.AdjustHealth(-dmgAmount); - hurtOrHealMessageContent += $"The player hurts themselves for {dmgAmount} damage. The player has {campaign.Player.CurrentHealth} health remaining. Mention these numbers in your response."; + hurtOrHealMessageContent += + $"The player hurts themselves for {dmgAmount} damage. The player has {campaign.Player.CurrentHealth} health remaining. Mention these numbers in your response."; if (playerDied) { hurtOrHealMessageContent += "The player has died and their adventure ends."; @@ -136,25 +150,28 @@ private async Task DetermineAndPerformHurtOrHeal(Campaign campaign, ICollection< if (hurtOrHealMessageContent != "") { - OpenAiGptMessage hurtOrHealSystemMessage = new(ChatMessageRole.System, hurtOrHealMessageContent); + OpenAiGptMessage hurtOrHealSystemMessage = new(MessageRole.System, hurtOrHealMessageContent); conversation.Add(hurtOrHealSystemMessage); } } private async Task DetermineOpponent(Campaign campaign, IList conversation) { - string opponentDescriptionString = await _llmClient.GetChatCompletion(conversation, _systemPrompts[SystemPromptType.CombatOpponentDescription]); - _logger.LogInformation("Opponent description response: {opponentDescriptionString}", opponentDescriptionString); - OpenAiGptMessage opponentDescriptionMessage = new(ChatMessageRole.Assistant, opponentDescriptionString); + string opponentDescriptionString = await _llmClient.GetChatCompletion(conversation, + _systemPrompts[SystemPromptType.CombatOpponentDescription]); + _logger.LogInformation("Opponent description response: {OpponentDescriptionString}", opponentDescriptionString); + OpenAiGptMessage opponentDescriptionMessage = new(MessageRole.Assistant, opponentDescriptionString); LlmResponse? opponentDescriptionResponse = opponentDescriptionMessage.TryParseFromJson(); LlmResponseCharacter? resChar = opponentDescriptionResponse?.Characters?.FirstOrDefault(); if (resChar != null) { Environment environment = campaign.Environments.Last(); - Character character = new(campaign, environment, GameStateManager.ParseToEnum(resChar.Type!, CharacterType.Humanoid), + Character character = new(campaign, environment, + GameStateManager.ParseToEnum(resChar.Type!, CharacterType.Humanoid), resChar.Name!, resChar.Description!, false); campaign.InsertOrUpdateCharacter(character); } + string? opponentName = opponentDescriptionResponse?.Opponent?.ToLower(); return campaign.Characters.LastOrDefault(c => !c.IsPlayer && c.Name.ToLower().Equals(opponentName)); } @@ -194,10 +211,12 @@ private static (int, int) ComputeCombatDamage(SystemPromptType combatOutcome, Ch case SystemPromptType.CombatMissMiss: break; } + return (playerDmg, opponentDmg); } - private void ConstructCombatSystemMessage(Campaign campaign, int playerDmg, int opponentDmg, Character opponent, IList conversation) + private void ConstructCombatSystemMessage(Campaign campaign, int playerDmg, int opponentDmg, Character opponent, + IList conversation) { string combatMessageContent = ""; if (playerDmg != 0) @@ -207,59 +226,65 @@ private void ConstructCombatSystemMessage(Campaign campaign, int playerDmg, int combatMessageContent += $" With no health points remaining, {opponent.Name} dies and can no longer participate in the narrative."; } - combatMessageContent += $"The player hits with their attack, dealing {playerDmg} damage. The opponent has {opponent.CurrentHealth} health remaining."; - _logger.LogInformation("Combat: {Name} hits {Name} for {x} damage. Health: {CurrentHealth}/{MaxHealth}", campaign.Player.Name, opponent.Name, playerDmg, opponent.CurrentHealth, opponent.MaxHealth); + + combatMessageContent += + $"The player hits with their attack, dealing {playerDmg} damage. The opponent has {opponent.CurrentHealth} health remaining."; + _logger.LogInformation( + "Combat: {PlayerName} hits {OpponentName} for {X} damage. Health: {CurrentHealth}/{MaxHealth}", + campaign.Player.Name, opponent.Name, playerDmg, opponent.CurrentHealth, opponent.MaxHealth); } else { - combatMessageContent += $"The player misses with their attack, dealing no damage. The opponent has {opponent.CurrentHealth} health remaining."; + combatMessageContent += + $"The player misses with their attack, dealing no damage. The opponent has {opponent.CurrentHealth} health remaining."; } if (opponentDmg != 0) { bool playerDied = campaign.Player.AdjustHealth(-opponentDmg); - combatMessageContent += $"The opponent will hit with their next attack, dealing {opponentDmg} damage. The player has {campaign.Player.CurrentHealth} health remaining."; + combatMessageContent += + $"The opponent will hit with their next attack, dealing {opponentDmg} damage. The player has {campaign.Player.CurrentHealth} health remaining."; if (playerDied) { combatMessageContent += "The player has died and their adventure ends."; } - _logger.LogInformation("Combat: {Name} hits {Name} for {x} damage. Health: {CurrentHealth}/{MaxHealth}", opponent.Name, campaign.Player.Name, opponentDmg, campaign.Player.CurrentHealth, campaign.Player.MaxHealth); + + _logger.LogInformation( + "Combat: {OpponentName} hits {PlayerName} for {X} damage. Health: {CurrentHealth}/{MaxHealth}", + opponent.Name, campaign.Player.Name, opponentDmg, campaign.Player.CurrentHealth, + campaign.Player.MaxHealth); } else { - combatMessageContent += $"The opponent will miss their next attack, dealing no damage. The player has {campaign.Player.CurrentHealth} health remaining."; + combatMessageContent += + $"The opponent will miss their next attack, dealing no damage. The player has {campaign.Player.CurrentHealth} health remaining."; } - OpenAiGptMessage combatSystemMessage = new(ChatMessageRole.System, combatMessageContent); + OpenAiGptMessage combatSystemMessage = new(MessageRole.System, combatMessageContent); conversation.Add(combatSystemMessage); } - private async Task GetResponseAndUpdateState(Campaign campaign, IList conversation, string systemPrompt) + private async Task GetResponseAndUpdateState(Campaign campaign, string actionPrompt, string input) { - if (conversation.Any(m => m.Role.Equals(ChatMessageRole.User))) - { - _gameStateManager.UpdateStateFromMessage(campaign, conversation.Last(m => m.Role.Equals(ChatMessageRole.User))); - } if (_streamChatCompletions) { - OpenAiGptMessage message = new(ChatMessageRole.Assistant, ""); + OpenAiGptMessage message = new(MessageRole.Assistant, ""); OnChatCompletionReceived(message); - await foreach (string chunk in _llmClient.GetStreamedChatCompletion(conversation, systemPrompt)) + await foreach (var chunk in _llmClient.GetStreamedChatCompletionAsync(campaign, actionPrompt, input)) { OnChatCompletionChunkReceived(isStreamingDone: false, chunk); } + OnChatCompletionChunkReceived(isStreamingDone: true); - _gameStateManager.UpdateStateFromMessage(campaign, message); - await _gameStateManager.SaveCurrentState(campaign); } else { - string response = await _llmClient.GetChatCompletion(conversation, systemPrompt); - OpenAiGptMessage message = new(ChatMessageRole.Assistant, response); + string response = await _llmClient.GetChatCompletionAsync(campaign, actionPrompt, input); + OpenAiGptMessage message = new(MessageRole.Assistant, response); OnChatCompletionReceived(message); - _gameStateManager.UpdateStateFromMessage(campaign, message); - await _gameStateManager.SaveCurrentState(campaign); } + + await _gameStateManager.SaveCurrentState(campaign); } } diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index 2d5ba85..a2be169 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -1,68 +1,12 @@ -using ChatRPG.API; -using ChatRPG.API.Response; using ChatRPG.Data.Models; -using OpenAI_API.Chat; -using Environment = ChatRPG.Data.Models.Environment; namespace ChatRPG.Services; -public class GameStateManager +public class GameStateManager(IPersistenceService persistenceService) { - private readonly ILogger _logger; - private readonly IPersistenceService _persistenceService; - - public GameStateManager(ILogger logger, IPersistenceService persistenceService) - { - _logger = logger; - _persistenceService = persistenceService; - } - - public void UpdateStateFromMessage(Campaign campaign, OpenAiGptMessage gptMessage) - { - Message message = new(campaign, ParseToEnum(gptMessage.Role.ToString()!, MessageRole.User), gptMessage.Content); - campaign.Messages.Add(message); - if (gptMessage.Role.Equals(ChatMessageRole.Assistant)) - { - UpdateStateFromResponse(campaign, gptMessage); - } - } - public async Task SaveCurrentState(Campaign campaign) { - await _persistenceService.SaveAsync(campaign); - } - - private void UpdateStateFromResponse(Campaign campaign, OpenAiGptMessage message) - { - try - { - LlmResponse? response = message.TryParseFromJson(); - if (response is null) return; - - if (response.Environment is { Name: not null, Description: not null }) - { - Environment environment = new(campaign, response.Environment.Name, response.Environment.Description); - environment = campaign.InsertOrUpdateEnvironment(environment); - campaign.Player.Environment = environment; - _logger.LogInformation("Set environment: \"{Name}\"", environment.Name); - } - - if (response.Characters is not null) - { - foreach (LlmResponseCharacter resChar in response.Characters.Where(c => c is { Name: not null, Description: not null, Type: not null })) - { - Environment environment = campaign.Environments.Last(); - Character character = new(campaign, environment, ParseToEnum(resChar.Type!, CharacterType.Humanoid), - resChar.Name!, resChar.Description!, false); - campaign.InsertOrUpdateCharacter(character); - _logger.LogInformation("Created character: \"{Name}\"", character.Name); - } - } - } - catch (Exception e) - { - _logger.LogError(e, "Failed to parse message content as response: \"{Content}\"", message.Content); - } + await persistenceService.SaveAsync(campaign); } public static T ParseToEnum(string input, T defaultVal) where T : struct, Enum diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index aab5b2e..ef83977 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -17,6 +17,7 @@ "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { + "ReAct": "", "Initial": "You are an expert game master in an RPG. You direct the narrative and control non-player characters. The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" } Where \"characters\" includes any new characters met by the player, describing them concisely here in this way: { \"name\": \"Name of the character\", \"description\": \"Short description\", \"type\": \"Humanoid, SmallCreature, LargeCreature or Monster\" }. \"environment\" is filled out when the player enters a new location, describe it shortly here in the format: { \"name\": \"environment\", \"description\": \"short description\" }.", "CombatHitHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" }.", "CombatHitMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack will miss. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", diff --git a/ChatRPGTests/GameStateManagerTests.cs b/ChatRPGTests/GameStateManagerTests.cs index 644916c..33e48fe 100644 --- a/ChatRPGTests/GameStateManagerTests.cs +++ b/ChatRPGTests/GameStateManagerTests.cs @@ -18,9 +18,8 @@ public class GameStateManagerTests public GameStateManagerTests() { - ILogger logger = Mock.Of>(); IPersistenceService persistenceService = Mock.Of(); - _parser = new GameStateManager(logger, persistenceService); + _parser = new GameStateManager(persistenceService); _user = new User("test"); _campaign = new Campaign(_user, "Test"); _environment = new Environment(_campaign, "Environment", "Test environment"); From 5bd62d52b256d442b7e590eff69107c009ec0fb1 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 14 Oct 2024 17:29:05 +0200 Subject: [PATCH 09/51] Fix --- ChatRPG/API/IReActLlmClient.cs | 1 - .../API/Memory/ChatRPGConversationMemory.cs | 34 +- ChatRPG/API/Memory/ChatRPGSummarizer.cs | 2 +- ChatRPG/API/OpenAiGptMessage.cs | 41 -- ChatRPG/API/OpenAiLlmClient.cs | 3 +- ChatRPG/API/ReActAgentChain.cs | 8 + ChatRPG/API/ReActLlmClient.cs | 68 +- ChatRPG/ChatRPG.csproj | 4 + .../20241014080025_AddGameSummary.Designer.cs | 581 ++++++++++++++++++ .../20241014080025_AddGameSummary.cs | 40 ++ ...120637_MakeGameSummaryNotEmpty.Designer.cs | 581 ++++++++++++++++++ .../20241014120637_MakeGameSummaryNotEmpty.cs | 22 + ...4121558_MakeGameSummaryNotNull.Designer.cs | 581 ++++++++++++++++++ .../20241014121558_MakeGameSummaryNotNull.cs | 22 + ...122004_MakeGameSummaryNullable.Designer.cs | 580 +++++++++++++++++ .../20241014122004_MakeGameSummaryNullable.cs | 36 ++ .../ApplicationDbContextModelSnapshot.cs | 6 +- ChatRPG/Data/Models/Campaign.cs | 2 +- ChatRPG/Pages/OpenAiGptMessageComponent.razor | 2 +- ChatRPG/Program.cs | 4 +- ChatRPG/Services/EfPersistenceService.cs | 43 +- ChatRPG/Services/GameInputHandler.cs | 25 +- ChatRPG/appsettings.json | 2 +- ChatRPGTests/GameStateManagerTests.cs | 57 -- 24 files changed, 2568 insertions(+), 177 deletions(-) create mode 100644 ChatRPG/Data/Migrations/20241014080025_AddGameSummary.Designer.cs create mode 100644 ChatRPG/Data/Migrations/20241014080025_AddGameSummary.cs create mode 100644 ChatRPG/Data/Migrations/20241014120637_MakeGameSummaryNotEmpty.Designer.cs create mode 100644 ChatRPG/Data/Migrations/20241014120637_MakeGameSummaryNotEmpty.cs create mode 100644 ChatRPG/Data/Migrations/20241014121558_MakeGameSummaryNotNull.Designer.cs create mode 100644 ChatRPG/Data/Migrations/20241014121558_MakeGameSummaryNotNull.cs create mode 100644 ChatRPG/Data/Migrations/20241014122004_MakeGameSummaryNullable.Designer.cs create mode 100644 ChatRPG/Data/Migrations/20241014122004_MakeGameSummaryNullable.cs delete mode 100644 ChatRPGTests/GameStateManagerTests.cs diff --git a/ChatRPG/API/IReActLlmClient.cs b/ChatRPG/API/IReActLlmClient.cs index d851e34..2a0b21c 100644 --- a/ChatRPG/API/IReActLlmClient.cs +++ b/ChatRPG/API/IReActLlmClient.cs @@ -1,5 +1,4 @@ using ChatRPG.Data.Models; -using ChatRPG.Pages; namespace ChatRPG.API; diff --git a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs index c458b5f..a05a992 100644 --- a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs +++ b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs @@ -3,30 +3,24 @@ using LangChain.Memory; using LangChain.Providers; using LangChain.Schema; -using Microsoft.AspNetCore.Components; using Message = LangChain.Providers.Message; using MessageRole = LangChain.Providers.MessageRole; namespace ChatRPG.API.Memory; -public class ChatRPGConversationMemory : BaseChatMemory +public class ChatRPGConversationMemory(IChatModel model, string? summary) + : BaseChatMemory { - private IChatModel Model { get; } - private Campaign Campaign { get; } - [Inject] private GameStateManager? GameStateManager { get; set; } + private IChatModel Model { get; } = model ?? throw new ArgumentNullException(nameof(model)); + public string? Summary { get; set; } = summary; + public Dictionary Messages = new(); public string MemoryKey { get; set; } = "history"; public override List MemoryVariables => [MemoryKey]; - public ChatRPGConversationMemory(Campaign campaign, IChatModel model) - { - Campaign = campaign; - Model = model ?? throw new ArgumentNullException(nameof(model)); - } - public override OutputValues LoadMemoryVariables(InputValues? inputValues) { - return new OutputValues(new Dictionary { { MemoryKey, Campaign.GameSummary } }); + return new OutputValues(new Dictionary { { MemoryKey, Summary ?? ""} }); } public override async Task SaveContext(InputValues inputValues, OutputValues outputValues) @@ -41,18 +35,26 @@ public override async Task SaveContext(InputValues inputValues, OutputValues out var humanMessageContent = inputValues.Value[inputKey].ToString() ?? string.Empty; newMessages.Add(new Message(humanMessageContent, MessageRole.Human)); - Campaign.Messages.Add(new Data.Models.Message(Campaign, Data.Models.MessageRole.User, humanMessageContent)); + + Messages.Add(Data.Models.MessageRole.User, humanMessageContent); // If the OutputKey is not specified, there must only be one output value var outputKey = OutputKey ?? outputValues.Value.Keys.Single(); var aiMessageContent = outputValues.Value[outputKey].ToString() ?? string.Empty; + int finalAnswerIndex = aiMessageContent.IndexOf("Final Answer: ", StringComparison.Ordinal); + if (finalAnswerIndex != -1) + { + // Only keep final answer + int startOutputIndex = finalAnswerIndex + "Final Answer: ".Length; + aiMessageContent = aiMessageContent[startOutputIndex..]; + } + newMessages.Add(new Message(aiMessageContent, MessageRole.Ai)); - Campaign.Messages.Add(new Data.Models.Message(Campaign, Data.Models.MessageRole.Assistant, aiMessageContent)); - Campaign.GameSummary = await Model.SummarizeAsync(newMessages, Campaign.GameSummary).ConfigureAwait(false); + Messages.Add(Data.Models.MessageRole.Assistant, aiMessageContent); - await GameStateManager!.SaveCurrentState(Campaign); + Summary = await Model.SummarizeAsync(newMessages, Summary ?? "").ConfigureAwait(true); } public override async Task Clear() diff --git a/ChatRPG/API/Memory/ChatRPGSummarizer.cs b/ChatRPG/API/Memory/ChatRPGSummarizer.cs index 160c9b7..07424af 100644 --- a/ChatRPG/API/Memory/ChatRPGSummarizer.cs +++ b/ChatRPG/API/Memory/ChatRPGSummarizer.cs @@ -48,6 +48,6 @@ public static async Task SummarizeAsync( | Template(SummaryPrompt) | LLM(chatModel); - return await chain.RunAsync("text", cancellationToken: cancellationToken).ConfigureAwait(false) ?? string.Empty; + return await chain.RunAsync("text", cancellationToken: cancellationToken).ConfigureAwait(true) ?? string.Empty; } } diff --git a/ChatRPG/API/OpenAiGptMessage.cs b/ChatRPG/API/OpenAiGptMessage.cs index d375de5..cf834b6 100644 --- a/ChatRPG/API/OpenAiGptMessage.cs +++ b/ChatRPG/API/OpenAiGptMessage.cs @@ -1,9 +1,5 @@ -using System.Text.Json; -using System.Text.RegularExpressions; -using ChatRPG.API.Response; using ChatRPG.Data.Models; using ChatRPG.Pages; -using Microsoft.IdentityModel.Tokens; namespace ChatRPG.API; @@ -13,13 +9,6 @@ public OpenAiGptMessage(MessageRole role, string content) { Role = role; Content = content; - NarrativePart = ""; - UpdateNarrativePart(); - if (!Content.IsNullOrEmpty() && NarrativePart.IsNullOrEmpty() && - role.Equals(LangChain.Providers.MessageRole.Ai)) - { - NarrativePart = Content; - } } public OpenAiGptMessage(MessageRole role, string content, UserPromptType userPromptType) : this(role, content) @@ -29,41 +18,11 @@ public OpenAiGptMessage(MessageRole role, string content, UserPromptType userPro public MessageRole Role { get; } public string Content { get; private set; } - public string NarrativePart { get; private set; } public readonly UserPromptType UserPromptType = UserPromptType.Do; - private static readonly Regex NarrativeRegex = - new(pattern: "^\\s*{\\s*\"narrative\":\\s*\"([^\"]*)", RegexOptions.IgnoreCase); - - public LlmResponse? TryParseFromJson() - { - try - { - JsonSerializerOptions options = new() - { - PropertyNameCaseInsensitive = true - }; - return JsonSerializer.Deserialize(Content, options); - } - catch (JsonException) - { - return new LlmResponse { Narrative = Content }; // Format was unexpected - } - } - public void AddChunk(string chunk) { Content += chunk.Replace("\\\"", "'"); - UpdateNarrativePart(); - } - - private void UpdateNarrativePart() - { - Match match = NarrativeRegex.Match(Content); - if (match is { Success: true, Groups.Count: 2 }) - { - NarrativePart = match.Groups[1].ToString(); - } } public static OpenAiGptMessage FromMessage(Message message) diff --git a/ChatRPG/API/OpenAiLlmClient.cs b/ChatRPG/API/OpenAiLlmClient.cs index ca0044c..5e5ecf5 100644 --- a/ChatRPG/API/OpenAiLlmClient.cs +++ b/ChatRPG/API/OpenAiLlmClient.cs @@ -1,4 +1,4 @@ -using Microsoft.IdentityModel.Tokens; +/*using Microsoft.IdentityModel.Tokens; using OpenAI_API; using OpenAI_API.Chat; @@ -50,3 +50,4 @@ private Conversation CreateConversation(IList messages, string return chat; } } +*/ diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index b739b85..ffef5fa 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -17,6 +17,7 @@ public sealed class ReActAgentChain : BaseStackableChain private readonly int _maxActions; private readonly IChatModel _model; private readonly string _reActPrompt; + private readonly string _actionPrompt; private readonly bool _useStreaming; private StackChain? _chain; private readonly Dictionary _tools = new(); @@ -56,6 +57,9 @@ public sealed class ReActAgentChain : BaseStackableChain Always add [END] after final answer +Special action: +{action} + Begin! Previous conversation history: @@ -67,6 +71,7 @@ public ReActAgentChain( IChatModel model, BaseChatMemory memory, string? reActPrompt = null, + string? actionPrompt = null, string inputKey = "input", string outputKey = "text", int maxActions = 10, @@ -74,6 +79,7 @@ public ReActAgentChain( { _model = model; _reActPrompt = reActPrompt ?? DefaultPrompt; + _actionPrompt = actionPrompt ?? string.Empty; _maxActions = maxActions; InputKeys = [inputKey]; @@ -92,6 +98,7 @@ private void InitializeChain() Set(() => _userInput, "input") | Set(tools, "tools") | Set(toolNames, "tool_names") + | Set(_actionPrompt, "action") | LoadMemory(_conversationSummaryMemory, "history") | Template(_reActPrompt) | LLM(_model, settings: new ChatSettings @@ -102,6 +109,7 @@ private void InitializeChain() | UpdateMemory(_conversationSummaryMemory, "input", "text") | ReActParser("text", ReActAnswer); + _chain = chain; } diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 9992535..3503555 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -1,6 +1,5 @@ using ChatRPG.API.Memory; using ChatRPG.Data.Models; -using ChatRPG.Pages; using LangChain.Providers.OpenAI; using LangChain.Providers.OpenAI.Predefined; using static LangChain.Chains.Chain; @@ -9,7 +8,7 @@ namespace ChatRPG.API; public class ReActLlmClient : IReActLlmClient { - private readonly Gpt4Model _llm; + private readonly OpenAiProvider _provider; private readonly string _reActPrompt; public ReActLlmClient(IConfiguration configuration) @@ -17,30 +16,69 @@ public ReActLlmClient(IConfiguration configuration) ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")); ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts")?.GetValue("ReAct")); _reActPrompt = configuration.GetSection("SystemPrompts")?.GetValue("ReAct")!; - var provider = new OpenAiProvider(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")!); - _llm = new Gpt4Model(provider); + _provider = new OpenAiProvider(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")!); } public async Task GetChatCompletionAsync(Campaign campaign, string actionPrompt, string input) { - var memory = new ChatRPGConversationMemory(campaign, _llm); - var agent = new ReActAgentChain(_llm, memory, _reActPrompt, useStreaming: false); + var llm = new Gpt4Model(_provider) + { + Settings = new OpenAiChatSettings() {UseStreaming = false} + }; + var memory = new ChatRPGConversationMemory(llm, campaign.GameSummary); + var agent = new ReActAgentChain(llm, memory, _reActPrompt, "", useStreaming: false); //agent.UseTool(); - var chain = Set(actionPrompt, "action") | Set(input, "input") | agent; - return (await chain.RunAsync("text"))!; + var chain = Set(input, "input") | agent; + var result = await chain.RunAsync("text"); + + UpdateCampaign(campaign, memory); + + return result!; } - public IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt, string input) + public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt, string input) { - var eventProcessor = new LlmEventProcessor(_llm); - var memory = new ChatRPGConversationMemory(campaign, _llm); - var agent = new ReActAgentChain(_llm, memory, _reActPrompt, useStreaming: false); + var agentLlm = new Gpt4Model(_provider) + { + Settings = new OpenAiChatSettings() {UseStreaming = true} + }; + + var memoryLlm = new Gpt4Model(_provider) + { + Settings = new OpenAiChatSettings() {UseStreaming = false} + }; + + var eventProcessor = new LlmEventProcessor(agentLlm); + var memory = new ChatRPGConversationMemory(memoryLlm, campaign.GameSummary); + var agent = new ReActAgentChain(agentLlm, memory, _reActPrompt, "", useStreaming: true); //agent.UseTool(); - var chain = Set(actionPrompt, "action") | Set(input, "input") | agent; - _ = Task.Run(async () => await chain.RunAsync()); + var chain = Set(input, "input") | agent; + + var result = chain.RunAsync(); - return eventProcessor.GetContentStreamAsync(); + await foreach (var content in eventProcessor.GetContentStreamAsync()) + { + yield return content; + } + + await result; + + UpdateCampaign(campaign, memory); + } + + private static void UpdateCampaign(Campaign campaign, ChatRPGConversationMemory memory) + { + campaign.GameSummary = memory.Summary; + foreach (var (role, message) in memory.Messages) + { + // Only add the message, is the list is empty. + // This is because if the list is empty, the input is the initial prompt. Not player input. + if (campaign.Messages.Count != 0) + { + campaign.Messages.Add(new Message(campaign, role, message)); + } + } } } diff --git a/ChatRPG/ChatRPG.csproj b/ChatRPG/ChatRPG.csproj index 4c5b4c5..7f6541d 100644 --- a/ChatRPG/ChatRPG.csproj +++ b/ChatRPG/ChatRPG.csproj @@ -36,4 +36,8 @@ + + + + diff --git a/ChatRPG/Data/Migrations/20241014080025_AddGameSummary.Designer.cs b/ChatRPG/Data/Migrations/20241014080025_AddGameSummary.Designer.cs new file mode 100644 index 0000000..fe86396 --- /dev/null +++ b/ChatRPG/Data/Migrations/20241014080025_AddGameSummary.Designer.cs @@ -0,0 +1,581 @@ +// +using System; +using ChatRPG.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20241014080025_AddGameSummary")] + partial class AddGameSummary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Abilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameSummary") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartScenario") + .HasColumnType("text"); + + b.Property("StartScenarioId") + .HasColumnType("integer"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StartScenarioId"); + + b.HasIndex("UserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("CurrentHealth") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("IsPlayer") + .HasColumnType("boolean"); + + b.Property("MaxHealth") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => + { + b.Property("CharacterId") + .HasColumnType("integer"); + + b.Property("AbilityId") + .HasColumnType("integer"); + + b.HasKey("CharacterId", "AbilityId"); + + b.HasIndex("AbilityId"); + + b.ToTable("CharacterAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Message"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("StartScenarios"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.HasOne("ChatRPG.Data.Models.StartScenario", null) + .WithMany("Campaigns") + .HasForeignKey("StartScenarioId"); + + b.HasOne("ChatRPG.Data.Models.User", "User") + .WithMany("Campaigns") + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Characters") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Environment", "Environment") + .WithMany("Characters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => + { + b.HasOne("ChatRPG.Data.Models.Ability", "Ability") + .WithMany("CharactersAbilities") + .HasForeignKey("AbilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Character", "Character") + .WithMany("CharacterAbilities") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ability"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Environments") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Messages") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => + { + b.Navigation("CharactersAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Navigation("Characters"); + + b.Navigation("Environments"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Navigation("CharacterAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.Navigation("Characters"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Navigation("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Navigation("Campaigns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ChatRPG/Data/Migrations/20241014080025_AddGameSummary.cs b/ChatRPG/Data/Migrations/20241014080025_AddGameSummary.cs new file mode 100644 index 0000000..f8a7ae5 --- /dev/null +++ b/ChatRPG/Data/Migrations/20241014080025_AddGameSummary.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + /// + public partial class AddGameSummary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CombatMode", + table: "Campaigns"); + + migrationBuilder.AddColumn( + name: "GameSummary", + table: "Campaigns", + type: "text", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GameSummary", + table: "Campaigns"); + + migrationBuilder.AddColumn( + name: "CombatMode", + table: "Campaigns", + type: "boolean", + nullable: false, + defaultValue: false); + } + } +} diff --git a/ChatRPG/Data/Migrations/20241014120637_MakeGameSummaryNotEmpty.Designer.cs b/ChatRPG/Data/Migrations/20241014120637_MakeGameSummaryNotEmpty.Designer.cs new file mode 100644 index 0000000..afb80df --- /dev/null +++ b/ChatRPG/Data/Migrations/20241014120637_MakeGameSummaryNotEmpty.Designer.cs @@ -0,0 +1,581 @@ +// +using System; +using ChatRPG.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20241014120637_MakeGameSummaryNotEmpty")] + partial class MakeGameSummaryNotEmpty + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Abilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameSummary") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartScenario") + .HasColumnType("text"); + + b.Property("StartScenarioId") + .HasColumnType("integer"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StartScenarioId"); + + b.HasIndex("UserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("CurrentHealth") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("IsPlayer") + .HasColumnType("boolean"); + + b.Property("MaxHealth") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => + { + b.Property("CharacterId") + .HasColumnType("integer"); + + b.Property("AbilityId") + .HasColumnType("integer"); + + b.HasKey("CharacterId", "AbilityId"); + + b.HasIndex("AbilityId"); + + b.ToTable("CharacterAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Message"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("StartScenarios"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.HasOne("ChatRPG.Data.Models.StartScenario", null) + .WithMany("Campaigns") + .HasForeignKey("StartScenarioId"); + + b.HasOne("ChatRPG.Data.Models.User", "User") + .WithMany("Campaigns") + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Characters") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Environment", "Environment") + .WithMany("Characters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => + { + b.HasOne("ChatRPG.Data.Models.Ability", "Ability") + .WithMany("CharactersAbilities") + .HasForeignKey("AbilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Character", "Character") + .WithMany("CharacterAbilities") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ability"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Environments") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Messages") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => + { + b.Navigation("CharactersAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Navigation("Characters"); + + b.Navigation("Environments"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Navigation("CharacterAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.Navigation("Characters"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Navigation("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Navigation("Campaigns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ChatRPG/Data/Migrations/20241014120637_MakeGameSummaryNotEmpty.cs b/ChatRPG/Data/Migrations/20241014120637_MakeGameSummaryNotEmpty.cs new file mode 100644 index 0000000..f235bad --- /dev/null +++ b/ChatRPG/Data/Migrations/20241014120637_MakeGameSummaryNotEmpty.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + /// + public partial class MakeGameSummaryNotEmpty : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/ChatRPG/Data/Migrations/20241014121558_MakeGameSummaryNotNull.Designer.cs b/ChatRPG/Data/Migrations/20241014121558_MakeGameSummaryNotNull.Designer.cs new file mode 100644 index 0000000..db3e541 --- /dev/null +++ b/ChatRPG/Data/Migrations/20241014121558_MakeGameSummaryNotNull.Designer.cs @@ -0,0 +1,581 @@ +// +using System; +using ChatRPG.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20241014121558_MakeGameSummaryNotNull")] + partial class MakeGameSummaryNotNull + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Abilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameSummary") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartScenario") + .HasColumnType("text"); + + b.Property("StartScenarioId") + .HasColumnType("integer"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StartScenarioId"); + + b.HasIndex("UserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("CurrentHealth") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("IsPlayer") + .HasColumnType("boolean"); + + b.Property("MaxHealth") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => + { + b.Property("CharacterId") + .HasColumnType("integer"); + + b.Property("AbilityId") + .HasColumnType("integer"); + + b.HasKey("CharacterId", "AbilityId"); + + b.HasIndex("AbilityId"); + + b.ToTable("CharacterAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Message"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("StartScenarios"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.HasOne("ChatRPG.Data.Models.StartScenario", null) + .WithMany("Campaigns") + .HasForeignKey("StartScenarioId"); + + b.HasOne("ChatRPG.Data.Models.User", "User") + .WithMany("Campaigns") + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Characters") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Environment", "Environment") + .WithMany("Characters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => + { + b.HasOne("ChatRPG.Data.Models.Ability", "Ability") + .WithMany("CharactersAbilities") + .HasForeignKey("AbilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Character", "Character") + .WithMany("CharacterAbilities") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ability"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Environments") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Messages") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => + { + b.Navigation("CharactersAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Navigation("Characters"); + + b.Navigation("Environments"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Navigation("CharacterAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.Navigation("Characters"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Navigation("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Navigation("Campaigns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ChatRPG/Data/Migrations/20241014121558_MakeGameSummaryNotNull.cs b/ChatRPG/Data/Migrations/20241014121558_MakeGameSummaryNotNull.cs new file mode 100644 index 0000000..6417634 --- /dev/null +++ b/ChatRPG/Data/Migrations/20241014121558_MakeGameSummaryNotNull.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + /// + public partial class MakeGameSummaryNotNull : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/ChatRPG/Data/Migrations/20241014122004_MakeGameSummaryNullable.Designer.cs b/ChatRPG/Data/Migrations/20241014122004_MakeGameSummaryNullable.Designer.cs new file mode 100644 index 0000000..9f95af2 --- /dev/null +++ b/ChatRPG/Data/Migrations/20241014122004_MakeGameSummaryNullable.Designer.cs @@ -0,0 +1,580 @@ +// +using System; +using ChatRPG.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20241014122004_MakeGameSummaryNullable")] + partial class MakeGameSummaryNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Abilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameSummary") + .HasColumnType("text"); + + b.Property("StartScenario") + .HasColumnType("text"); + + b.Property("StartScenarioId") + .HasColumnType("integer"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StartScenarioId"); + + b.HasIndex("UserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("CurrentHealth") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("IsPlayer") + .HasColumnType("boolean"); + + b.Property("MaxHealth") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => + { + b.Property("CharacterId") + .HasColumnType("integer"); + + b.Property("AbilityId") + .HasColumnType("integer"); + + b.HasKey("CharacterId", "AbilityId"); + + b.HasIndex("AbilityId"); + + b.ToTable("CharacterAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Message"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("StartScenarios"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.HasOne("ChatRPG.Data.Models.StartScenario", null) + .WithMany("Campaigns") + .HasForeignKey("StartScenarioId"); + + b.HasOne("ChatRPG.Data.Models.User", "User") + .WithMany("Campaigns") + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Characters") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Environment", "Environment") + .WithMany("Characters") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => + { + b.HasOne("ChatRPG.Data.Models.Ability", "Ability") + .WithMany("CharactersAbilities") + .HasForeignKey("AbilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Character", "Character") + .WithMany("CharacterAbilities") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ability"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Environments") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Messages") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => + { + b.Navigation("CharactersAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Navigation("Characters"); + + b.Navigation("Environments"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Navigation("CharacterAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.Navigation("Characters"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Navigation("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Navigation("Campaigns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ChatRPG/Data/Migrations/20241014122004_MakeGameSummaryNullable.cs b/ChatRPG/Data/Migrations/20241014122004_MakeGameSummaryNullable.cs new file mode 100644 index 0000000..bd9da31 --- /dev/null +++ b/ChatRPG/Data/Migrations/20241014122004_MakeGameSummaryNullable.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + /// + public partial class MakeGameSummaryNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "GameSummary", + table: "Campaigns", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "GameSummary", + table: "Campaigns", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + } +} diff --git a/ChatRPG/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/ChatRPG/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 6ec99e3..92acc3b 100644 --- a/ChatRPG/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/ChatRPG/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.12") + .HasAnnotation("ProductVersion", "8.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -53,8 +53,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("CombatMode") - .HasColumnType("boolean"); + b.Property("GameSummary") + .HasColumnType("text"); b.Property("StartScenario") .HasColumnType("text"); diff --git a/ChatRPG/Data/Models/Campaign.cs b/ChatRPG/Data/Models/Campaign.cs index 5b13c43..ba2d0a6 100644 --- a/ChatRPG/Data/Models/Campaign.cs +++ b/ChatRPG/Data/Models/Campaign.cs @@ -24,7 +24,7 @@ public Campaign(User user, string title, string startScenario) : this(user, titl public string Title { get; private set; } = null!; public DateTime StartedOn { get; private set; } public ICollection Messages { get; } = new List(); - public string GameSummary { get; set; } = string.Empty; + public string? GameSummary { get; set; } public ICollection Characters { get; } = new List(); public ICollection Environments { get; } = new List(); public Character Player => Characters.First(c => c.IsPlayer); diff --git a/ChatRPG/Pages/OpenAiGptMessageComponent.razor b/ChatRPG/Pages/OpenAiGptMessageComponent.razor index 6f4f949..818900e 100644 --- a/ChatRPG/Pages/OpenAiGptMessageComponent.razor +++ b/ChatRPG/Pages/OpenAiGptMessageComponent.razor @@ -3,7 +3,7 @@ @inherits ComponentBase
-

@(MessagePrefix): @(Message.Role.Equals(Assistant) ? Message.NarrativePart : Message.Content)

+

@(MessagePrefix): @(Message.Content)

@code { diff --git a/ChatRPG/Program.cs b/ChatRPG/Program.cs index 13e01cf..98b2030 100644 --- a/ChatRPG/Program.cs +++ b/ChatRPG/Program.cs @@ -30,8 +30,8 @@ builder.Services.AddScoped>() .AddSingleton(httpMessageHandlerFactory) .AddSingleton() - .AddSingleton() - .AddTransient() + .AddTransient() + .AddScoped() .AddTransient() .AddTransient() .AddTransient() diff --git a/ChatRPG/Services/EfPersistenceService.cs b/ChatRPG/Services/EfPersistenceService.cs index abb803f..43f656d 100644 --- a/ChatRPG/Services/EfPersistenceService.cs +++ b/ChatRPG/Services/EfPersistenceService.cs @@ -8,35 +8,27 @@ namespace ChatRPG.Services; /// /// Service for persisting and loading changes from the data model using Entity Framework. /// -public class EfPersistenceService : IPersistenceService +public class EfPersistenceService(ILogger logger, ApplicationDbContext dbContext) + : IPersistenceService { - private readonly ILogger _logger; - private readonly ApplicationDbContext _dbContext; - - public EfPersistenceService(ILogger logger, ApplicationDbContext dbContext) - { - _logger = logger; - _dbContext = dbContext; - } - /// public async Task SaveAsync(Campaign campaign) { - await using IDbContextTransaction transaction = await _dbContext.Database.BeginTransactionAsync(); + await using IDbContextTransaction transaction = await dbContext.Database.BeginTransactionAsync(); try { - if (!(await _dbContext.Campaigns.ContainsAsync(campaign))) + if (!(await dbContext.Campaigns.ContainsAsync(campaign))) { - await _dbContext.Campaigns.AddAsync(campaign); + await dbContext.Campaigns.AddAsync(campaign); } - await _dbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(); await transaction.CommitAsync(); - _logger.LogInformation("Saved campaign with id {Id} successfully", campaign.Id); + logger.LogInformation("Saved campaign with id {Id} successfully", campaign.Id); } catch (Exception e) { - _logger.LogError(e, "An error occurred while saving"); + logger.LogError(e, "An error occurred while saving"); await transaction.RollbackAsync(); throw; } @@ -45,24 +37,24 @@ public async Task SaveAsync(Campaign campaign) /// public async Task DeleteAsync(Campaign campaign) { - if (!(await _dbContext.Campaigns.ContainsAsync(campaign))) + if (!(await dbContext.Campaigns.ContainsAsync(campaign))) { return; } - await using IDbContextTransaction transaction = await _dbContext.Database.BeginTransactionAsync(); + await using IDbContextTransaction transaction = await dbContext.Database.BeginTransactionAsync(); try { int campaignId = campaign.Id; - _dbContext.Campaigns.Remove(campaign); + dbContext.Campaigns.Remove(campaign); - await _dbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(); await transaction.CommitAsync(); - _logger.LogInformation("Deleted campaign with id {Id} successfully", campaignId); + logger.LogInformation("Deleted campaign with id {Id} successfully", campaignId); } catch (Exception e) { - _logger.LogError(e, "An error occurred while deleting"); + logger.LogError(e, "An error occurred while deleting"); await transaction.RollbackAsync(); throw; } @@ -71,10 +63,9 @@ public async Task DeleteAsync(Campaign campaign) /// public async Task LoadFromCampaignIdAsync(int campaignId) { - return await _dbContext.Campaigns + return await dbContext.Campaigns .Where(campaign => campaign.Id == campaignId) .Include(campaign => campaign.Messages) - .Include(campaign => campaign.GameSummary) .Include(campaign => campaign.Environments) .Include(campaign => campaign.Characters) .ThenInclude(character => character.CharacterAbilities) @@ -86,7 +77,7 @@ public async Task LoadFromCampaignIdAsync(int campaignId) /// public async Task> GetCampaignsForUser(User user) { - return await _dbContext.Campaigns + return await dbContext.Campaigns .Where(campaign => campaign.User.Equals(user)) .Include(campaign => campaign.Characters.Where(c => c.IsPlayer)) .ToListAsync(); @@ -95,6 +86,6 @@ public async Task> GetCampaignsForUser(User user) /// public async Task> GetStartScenarios() { - return await _dbContext.StartScenarios.ToListAsync(); + return await dbContext.StartScenarios.ToListAsync(); } } diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index a1a0750..bba3eec 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -90,7 +90,7 @@ public async Task HandleInitialPrompt(Campaign campaign, string initialInput) _logger.LogInformation("Finished processing prompt"); } - private async Task GetRelevantSystemPrompt(Campaign campaign, IList conversation) + /*private async Task GetRelevantSystemPrompt(Campaign campaign, IList conversation) { UserPromptType userPromptType = conversation.Last(m => m.Role.Equals(MessageRole.User)).UserPromptType; @@ -116,9 +116,9 @@ private async Task GetRelevantSystemPrompt(Campaign campaign, IList conversation) + /*private async Task DetermineAndPerformHurtOrHeal(Campaign campaign, ICollection conversation) { OpenAiGptMessage lastUserMessage = conversation.Last(m => m.Role.Equals(MessageRole.User)); string hurtOrHealString = await _llmClient.GetChatCompletionAsync(new List() { lastUserMessage }, @@ -154,10 +154,10 @@ private async Task DetermineAndPerformHurtOrHeal(Campaign campaign, ICollection< conversation.Add(hurtOrHealSystemMessage); } } - - private async Task DetermineOpponent(Campaign campaign, IList conversation) +*/ + /* private async Task DetermineOpponent(Campaign campaign, IList conversation) { - string opponentDescriptionString = await _llmClient.GetChatCompletion(conversation, + string opponentDescriptionString = await _llmClient.GetChatCompletionAsync(conversation, _systemPrompts[SystemPromptType.CombatOpponentDescription]); _logger.LogInformation("Opponent description response: {OpponentDescriptionString}", opponentDescriptionString); OpenAiGptMessage opponentDescriptionMessage = new(MessageRole.Assistant, opponentDescriptionString); @@ -175,8 +175,8 @@ private async Task DetermineAndPerformHurtOrHeal(Campaign campaign, ICollection< string? opponentName = opponentDescriptionResponse?.Opponent?.ToLower(); return campaign.Characters.LastOrDefault(c => !c.IsPlayer && c.Name.ToLower().Equals(opponentName)); } - - private static SystemPromptType DetermineCombatOutcome() +*/ + /* private static SystemPromptType DetermineCombatOutcome() { Random rand = new Random(); double playerRoll = rand.NextDouble(); @@ -214,8 +214,8 @@ private static (int, int) ComputeCombatDamage(SystemPromptType combatOutcome, Ch return (playerDmg, opponentDmg); } - - private void ConstructCombatSystemMessage(Campaign campaign, int playerDmg, int opponentDmg, Character opponent, +*/ + /* private void ConstructCombatSystemMessage(Campaign campaign, int playerDmg, int opponentDmg, Character opponent, IList conversation) { string combatMessageContent = ""; @@ -263,7 +263,7 @@ private void ConstructCombatSystemMessage(Campaign campaign, int playerDmg, int OpenAiGptMessage combatSystemMessage = new(MessageRole.System, combatMessageContent); conversation.Add(combatSystemMessage); } - +*/ private async Task GetResponseAndUpdateState(Campaign campaign, string actionPrompt, string input) { if (_streamChatCompletions) @@ -277,6 +277,8 @@ private async Task GetResponseAndUpdateState(Campaign campaign, string actionPro } OnChatCompletionChunkReceived(isStreamingDone: true); + + } else { @@ -285,6 +287,7 @@ private async Task GetResponseAndUpdateState(Campaign campaign, string actionPro OnChatCompletionReceived(message); } + await _gameStateManager.SaveCurrentState(campaign); } } diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index ef83977..331d000 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -17,7 +17,7 @@ "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { - "ReAct": "", + "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation: the result of the action ``` When you have a response to say to the Human, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Previous conversation history: {history} The Assistant should take the following into account: {action} New input: {input}", "Initial": "You are an expert game master in an RPG. You direct the narrative and control non-player characters. The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" } Where \"characters\" includes any new characters met by the player, describing them concisely here in this way: { \"name\": \"Name of the character\", \"description\": \"Short description\", \"type\": \"Humanoid, SmallCreature, LargeCreature or Monster\" }. \"environment\" is filled out when the player enters a new location, describe it shortly here in the format: { \"name\": \"environment\", \"description\": \"short description\" }.", "CombatHitHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" }.", "CombatHitMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack will miss. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", diff --git a/ChatRPGTests/GameStateManagerTests.cs b/ChatRPGTests/GameStateManagerTests.cs deleted file mode 100644 index 33e48fe..0000000 --- a/ChatRPGTests/GameStateManagerTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -using ChatRPG.API; -using ChatRPG.Data.Models; -using ChatRPG.Services; -using Microsoft.Extensions.Logging; -using Moq; -using OpenAI_API.Chat; -using Environment = ChatRPG.Data.Models.Environment; - -namespace ChatRPGTests; - -public class GameStateManagerTests -{ - private readonly GameStateManager _parser; - private readonly User _user; - private readonly Campaign _campaign; - private readonly Character _player; - private readonly Environment _environment; - - public GameStateManagerTests() - { - IPersistenceService persistenceService = Mock.Of(); - _parser = new GameStateManager(persistenceService); - _user = new User("test"); - _campaign = new Campaign(_user, "Test"); - _environment = new Environment(_campaign, "Environment", "Test environment"); - _player = new Character(_campaign, _environment, CharacterType.Humanoid, "Player", "The player", true); - _campaign.Characters.Add(_player); - } - - [Fact] - public void GivenCompleteInput_BasicExample_UpdatesStateAsExpected() - { - OpenAiGptMessage message = new(ChatMessageRole.Assistant, """ - { - "narrative": "Welcome, Sarmilan, the bewitching mage from Eldoria. Your journey begins at the towering gates of the ancient city of Thundertop, known for its imposing architecture and bustling markets. Rumor has it that the city holds a secret - a powerful artifact known as the 'Eye of the Storm'.", - "characters": [ - { - "name": "Sarmilan", - "description": "A charming and attractive mage hailing from the mystical land of Eldoria.", - "type": "Humanoid", - "HealthPoints": 50 - } - ], - "environment": { - "name": "Thundertop City", - "description": "A sprawling ancient city with towering architecture, bustling markets and whispered secrets." - } - } - """); - - _parser.UpdateStateFromMessage(_campaign, message); - - Assert.Equal(1, _campaign.Messages.Count); - Assert.Contains(_campaign.Characters, c => c is { Name: "Sarmilan", Description: "A charming and attractive mage hailing from the mystical land of Eldoria.", Type: CharacterType.Humanoid }); - Assert.Equal("Thundertop City", _campaign.Environments.Last().Name); - } -} From fb4a2960d9967d0debe0e11264513dc7e575b546 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 14 Oct 2024 17:33:47 +0200 Subject: [PATCH 10/51] Linter errors --- .../API/Memory/ChatRPGConversationMemory.cs | 7 +- ChatRPG/API/ReActLlmClient.cs | 9 +- ChatRPG/Services/GameInputHandler.cs | 203 +++++++++--------- 3 files changed, 108 insertions(+), 111 deletions(-) diff --git a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs index a05a992..68e3926 100644 --- a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs +++ b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs @@ -1,5 +1,3 @@ -using ChatRPG.Data.Models; -using ChatRPG.Services; using LangChain.Memory; using LangChain.Providers; using LangChain.Schema; @@ -8,8 +6,7 @@ namespace ChatRPG.API.Memory; -public class ChatRPGConversationMemory(IChatModel model, string? summary) - : BaseChatMemory +public class ChatRPGConversationMemory(IChatModel model, string? summary) : BaseChatMemory { private IChatModel Model { get; } = model ?? throw new ArgumentNullException(nameof(model)); public string? Summary { get; set; } = summary; @@ -20,7 +17,7 @@ public class ChatRPGConversationMemory(IChatModel model, string? summary) public override OutputValues LoadMemoryVariables(InputValues? inputValues) { - return new OutputValues(new Dictionary { { MemoryKey, Summary ?? ""} }); + return new OutputValues(new Dictionary { { MemoryKey, Summary ?? "" } }); } public override async Task SaveContext(InputValues inputValues, OutputValues outputValues) diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 3503555..8a993bd 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -23,7 +23,7 @@ public async Task GetChatCompletionAsync(Campaign campaign, string actio { var llm = new Gpt4Model(_provider) { - Settings = new OpenAiChatSettings() {UseStreaming = false} + Settings = new OpenAiChatSettings() { UseStreaming = false } }; var memory = new ChatRPGConversationMemory(llm, campaign.GameSummary); var agent = new ReActAgentChain(llm, memory, _reActPrompt, "", useStreaming: false); @@ -37,16 +37,17 @@ public async Task GetChatCompletionAsync(Campaign campaign, string actio return result!; } - public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt, string input) + public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt, + string input) { var agentLlm = new Gpt4Model(_provider) { - Settings = new OpenAiChatSettings() {UseStreaming = true} + Settings = new OpenAiChatSettings() { UseStreaming = true } }; var memoryLlm = new Gpt4Model(_provider) { - Settings = new OpenAiChatSettings() {UseStreaming = false} + Settings = new OpenAiChatSettings() { UseStreaming = false } }; var eventProcessor = new LlmEventProcessor(agentLlm); diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index bba3eec..f50fbd3 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -81,6 +81,7 @@ public async Task HandleUserPrompt(Campaign campaign, UserPromptType promptType, default: throw new ArgumentOutOfRangeException(); } + _logger.LogInformation("Finished processing prompt"); } @@ -155,115 +156,115 @@ public async Task HandleInitialPrompt(Campaign campaign, string initialInput) } } */ - /* private async Task DetermineOpponent(Campaign campaign, IList conversation) - { - string opponentDescriptionString = await _llmClient.GetChatCompletionAsync(conversation, - _systemPrompts[SystemPromptType.CombatOpponentDescription]); - _logger.LogInformation("Opponent description response: {OpponentDescriptionString}", opponentDescriptionString); - OpenAiGptMessage opponentDescriptionMessage = new(MessageRole.Assistant, opponentDescriptionString); - LlmResponse? opponentDescriptionResponse = opponentDescriptionMessage.TryParseFromJson(); - LlmResponseCharacter? resChar = opponentDescriptionResponse?.Characters?.FirstOrDefault(); - if (resChar != null) - { - Environment environment = campaign.Environments.Last(); - Character character = new(campaign, environment, - GameStateManager.ParseToEnum(resChar.Type!, CharacterType.Humanoid), - resChar.Name!, resChar.Description!, false); - campaign.InsertOrUpdateCharacter(character); - } + /* private async Task DetermineOpponent(Campaign campaign, IList conversation) + { + string opponentDescriptionString = await _llmClient.GetChatCompletionAsync(conversation, + _systemPrompts[SystemPromptType.CombatOpponentDescription]); + _logger.LogInformation("Opponent description response: {OpponentDescriptionString}", opponentDescriptionString); + OpenAiGptMessage opponentDescriptionMessage = new(MessageRole.Assistant, opponentDescriptionString); + LlmResponse? opponentDescriptionResponse = opponentDescriptionMessage.TryParseFromJson(); + LlmResponseCharacter? resChar = opponentDescriptionResponse?.Characters?.FirstOrDefault(); + if (resChar != null) + { + Environment environment = campaign.Environments.Last(); + Character character = new(campaign, environment, + GameStateManager.ParseToEnum(resChar.Type!, CharacterType.Humanoid), + resChar.Name!, resChar.Description!, false); + campaign.InsertOrUpdateCharacter(character); + } - string? opponentName = opponentDescriptionResponse?.Opponent?.ToLower(); - return campaign.Characters.LastOrDefault(c => !c.IsPlayer && c.Name.ToLower().Equals(opponentName)); - } -*/ - /* private static SystemPromptType DetermineCombatOutcome() - { - Random rand = new Random(); - double playerRoll = rand.NextDouble(); - double opponentRoll = rand.NextDouble(); + string? opponentName = opponentDescriptionResponse?.Opponent?.ToLower(); + return campaign.Characters.LastOrDefault(c => !c.IsPlayer && c.Name.ToLower().Equals(opponentName)); + } + */ + /* private static SystemPromptType DetermineCombatOutcome() + { + Random rand = new Random(); + double playerRoll = rand.NextDouble(); + double opponentRoll = rand.NextDouble(); - if (playerRoll >= 0.3) - { - return opponentRoll >= 0.6 ? SystemPromptType.CombatHitHit : SystemPromptType.CombatHitMiss; - } + if (playerRoll >= 0.3) + { + return opponentRoll >= 0.6 ? SystemPromptType.CombatHitHit : SystemPromptType.CombatHitMiss; + } - return opponentRoll >= 0.5 ? SystemPromptType.CombatMissHit : SystemPromptType.CombatMissMiss; - } + return opponentRoll >= 0.5 ? SystemPromptType.CombatMissHit : SystemPromptType.CombatMissMiss; + } - private static (int, int) ComputeCombatDamage(SystemPromptType combatOutcome, CharacterType opponentType) - { - Random rand = new Random(); - int playerDmg = 0; - int opponentDmg = 0; - (int opponentMin, int opponentMax) = CharacterTypeDamageDict[opponentType]; - switch (combatOutcome) - { - case SystemPromptType.CombatHitHit: - playerDmg = rand.Next(PlayerDmgMin, PlayerDmgMax); - opponentDmg = rand.Next(opponentMin, opponentMax); - break; - case SystemPromptType.CombatHitMiss: - playerDmg = rand.Next(PlayerDmgMin, PlayerDmgMax); - break; - case SystemPromptType.CombatMissHit: - opponentDmg = rand.Next(opponentMin, opponentMax); - break; - case SystemPromptType.CombatMissMiss: - break; - } + private static (int, int) ComputeCombatDamage(SystemPromptType combatOutcome, CharacterType opponentType) + { + Random rand = new Random(); + int playerDmg = 0; + int opponentDmg = 0; + (int opponentMin, int opponentMax) = CharacterTypeDamageDict[opponentType]; + switch (combatOutcome) + { + case SystemPromptType.CombatHitHit: + playerDmg = rand.Next(PlayerDmgMin, PlayerDmgMax); + opponentDmg = rand.Next(opponentMin, opponentMax); + break; + case SystemPromptType.CombatHitMiss: + playerDmg = rand.Next(PlayerDmgMin, PlayerDmgMax); + break; + case SystemPromptType.CombatMissHit: + opponentDmg = rand.Next(opponentMin, opponentMax); + break; + case SystemPromptType.CombatMissMiss: + break; + } - return (playerDmg, opponentDmg); - } -*/ - /* private void ConstructCombatSystemMessage(Campaign campaign, int playerDmg, int opponentDmg, Character opponent, - IList conversation) - { - string combatMessageContent = ""; - if (playerDmg != 0) - { - if (opponent.AdjustHealth(-playerDmg)) - { - combatMessageContent += - $" With no health points remaining, {opponent.Name} dies and can no longer participate in the narrative."; - } + return (playerDmg, opponentDmg); + } + */ + /* private void ConstructCombatSystemMessage(Campaign campaign, int playerDmg, int opponentDmg, Character opponent, + IList conversation) + { + string combatMessageContent = ""; + if (playerDmg != 0) + { + if (opponent.AdjustHealth(-playerDmg)) + { + combatMessageContent += + $" With no health points remaining, {opponent.Name} dies and can no longer participate in the narrative."; + } - combatMessageContent += - $"The player hits with their attack, dealing {playerDmg} damage. The opponent has {opponent.CurrentHealth} health remaining."; - _logger.LogInformation( - "Combat: {PlayerName} hits {OpponentName} for {X} damage. Health: {CurrentHealth}/{MaxHealth}", - campaign.Player.Name, opponent.Name, playerDmg, opponent.CurrentHealth, opponent.MaxHealth); - } - else - { - combatMessageContent += - $"The player misses with their attack, dealing no damage. The opponent has {opponent.CurrentHealth} health remaining."; - } + combatMessageContent += + $"The player hits with their attack, dealing {playerDmg} damage. The opponent has {opponent.CurrentHealth} health remaining."; + _logger.LogInformation( + "Combat: {PlayerName} hits {OpponentName} for {X} damage. Health: {CurrentHealth}/{MaxHealth}", + campaign.Player.Name, opponent.Name, playerDmg, opponent.CurrentHealth, opponent.MaxHealth); + } + else + { + combatMessageContent += + $"The player misses with their attack, dealing no damage. The opponent has {opponent.CurrentHealth} health remaining."; + } - if (opponentDmg != 0) - { - bool playerDied = campaign.Player.AdjustHealth(-opponentDmg); - combatMessageContent += - $"The opponent will hit with their next attack, dealing {opponentDmg} damage. The player has {campaign.Player.CurrentHealth} health remaining."; - if (playerDied) - { - combatMessageContent += "The player has died and their adventure ends."; - } + if (opponentDmg != 0) + { + bool playerDied = campaign.Player.AdjustHealth(-opponentDmg); + combatMessageContent += + $"The opponent will hit with their next attack, dealing {opponentDmg} damage. The player has {campaign.Player.CurrentHealth} health remaining."; + if (playerDied) + { + combatMessageContent += "The player has died and their adventure ends."; + } - _logger.LogInformation( - "Combat: {OpponentName} hits {PlayerName} for {X} damage. Health: {CurrentHealth}/{MaxHealth}", - opponent.Name, campaign.Player.Name, opponentDmg, campaign.Player.CurrentHealth, - campaign.Player.MaxHealth); - } - else - { - combatMessageContent += - $"The opponent will miss their next attack, dealing no damage. The player has {campaign.Player.CurrentHealth} health remaining."; - } + _logger.LogInformation( + "Combat: {OpponentName} hits {PlayerName} for {X} damage. Health: {CurrentHealth}/{MaxHealth}", + opponent.Name, campaign.Player.Name, opponentDmg, campaign.Player.CurrentHealth, + campaign.Player.MaxHealth); + } + else + { + combatMessageContent += + $"The opponent will miss their next attack, dealing no damage. The player has {campaign.Player.CurrentHealth} health remaining."; + } - OpenAiGptMessage combatSystemMessage = new(MessageRole.System, combatMessageContent); - conversation.Add(combatSystemMessage); - } -*/ + OpenAiGptMessage combatSystemMessage = new(MessageRole.System, combatMessageContent); + conversation.Add(combatSystemMessage); + } + */ private async Task GetResponseAndUpdateState(Campaign campaign, string actionPrompt, string input) { if (_streamChatCompletions) @@ -277,8 +278,6 @@ private async Task GetResponseAndUpdateState(Campaign campaign, string actionPro } OnChatCompletionChunkReceived(isStreamingDone: true); - - } else { From fb413ab39a989b1a7edb1fcfe31c7d4075e13b33 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 21 Oct 2024 16:42:51 +0200 Subject: [PATCH 11/51] WoundCharacterTool and ToolUtilities --- .../API/Memory/ChatRPGConversationMemory.cs | 10 ++- ChatRPG/API/Memory/ChatRPGSummarizer.cs | 2 +- ChatRPG/API/ReActLlmClient.cs | 64 +++++++++++++--- ChatRPG/API/Tools/EffectInput.cs | 7 ++ ChatRPG/API/Tools/ToolUtilities.cs | 75 +++++++++++++++++++ ChatRPG/API/Tools/WoundCharacterTool.cs | 75 +++++++++++++++++++ ChatRPG/ChatRPG.csproj | 4 - ChatRPG/Services/GameStateManager.cs | 5 -- ChatRPG/appsettings.json | 10 ++- 9 files changed, 222 insertions(+), 30 deletions(-) create mode 100644 ChatRPG/API/Tools/EffectInput.cs create mode 100644 ChatRPG/API/Tools/ToolUtilities.cs create mode 100644 ChatRPG/API/Tools/WoundCharacterTool.cs diff --git a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs index 68e3926..b035c82 100644 --- a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs +++ b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs @@ -9,8 +9,8 @@ namespace ChatRPG.API.Memory; public class ChatRPGConversationMemory(IChatModel model, string? summary) : BaseChatMemory { private IChatModel Model { get; } = model ?? throw new ArgumentNullException(nameof(model)); - public string? Summary { get; set; } = summary; - public Dictionary Messages = new(); + public string? Summary { get; private set; } = summary; + public readonly List<(Data.Models.MessageRole, string)> Messages = new(); public string MemoryKey { get; set; } = "history"; public override List MemoryVariables => [MemoryKey]; @@ -33,7 +33,7 @@ public override async Task SaveContext(InputValues inputValues, OutputValues out var humanMessageContent = inputValues.Value[inputKey].ToString() ?? string.Empty; newMessages.Add(new Message(humanMessageContent, MessageRole.Human)); - Messages.Add(Data.Models.MessageRole.User, humanMessageContent); + Messages.Add((Data.Models.MessageRole.User, humanMessageContent)); // If the OutputKey is not specified, there must only be one output value var outputKey = OutputKey ?? outputValues.Value.Keys.Single(); @@ -49,9 +49,11 @@ public override async Task SaveContext(InputValues inputValues, OutputValues out newMessages.Add(new Message(aiMessageContent, MessageRole.Ai)); - Messages.Add(Data.Models.MessageRole.Assistant, aiMessageContent); + Messages.Add((Data.Models.MessageRole.Assistant, aiMessageContent)); Summary = await Model.SummarizeAsync(newMessages, Summary ?? "").ConfigureAwait(true); + + await base.SaveContext(inputValues, outputValues).ConfigureAwait(false); } public override async Task Clear() diff --git a/ChatRPG/API/Memory/ChatRPGSummarizer.cs b/ChatRPG/API/Memory/ChatRPGSummarizer.cs index 07424af..d2da8db 100644 --- a/ChatRPG/API/Memory/ChatRPGSummarizer.cs +++ b/ChatRPG/API/Memory/ChatRPGSummarizer.cs @@ -7,7 +7,7 @@ namespace ChatRPG.API.Memory; public static class ChatRPGSummarizer { public const string SummaryPrompt = @" -Progressively summarize the interaction between the player and the GM. The player describes their actions in response to the game world, and the GM narrates the outcome, revealing the next part of the adventure. Return a new summary based on each exchange. +Progressively summarize the interaction between the player and the GM. The player describes their actions in response to the game world, and the GM narrates the outcome, revealing the next part of the adventure. Return a new summary based on each exchange taking into account the previous summary. EXAMPLE Current summary: diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 8a993bd..1f7afb2 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -1,13 +1,19 @@ +using Anthropic; using ChatRPG.API.Memory; +using ChatRPG.API.Tools; using ChatRPG.Data.Models; +using LangChain.Chains.StackableChains.Agents.Tools; using LangChain.Providers.OpenAI; using LangChain.Providers.OpenAI.Predefined; using static LangChain.Chains.Chain; +using Message = ChatRPG.Data.Models.Message; +using MessageRole = ChatRPG.Data.Models.MessageRole; namespace ChatRPG.API; public class ReActLlmClient : IReActLlmClient { + private readonly IConfiguration _configuration; private readonly OpenAiProvider _provider; private readonly string _reActPrompt; @@ -15,19 +21,24 @@ public ReActLlmClient(IConfiguration configuration) { ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")); ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts")?.GetValue("ReAct")); - _reActPrompt = configuration.GetSection("SystemPrompts")?.GetValue("ReAct")!; - _provider = new OpenAiProvider(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")!); + _configuration = configuration; + _reActPrompt = _configuration.GetSection("SystemPrompts")?.GetValue("ReAct")!; + _provider = new OpenAiProvider(_configuration.GetSection("ApiKeys")?.GetValue("OpenAI")!); } public async Task GetChatCompletionAsync(Campaign campaign, string actionPrompt, string input) { var llm = new Gpt4Model(_provider) { - Settings = new OpenAiChatSettings() { UseStreaming = false } + Settings = new OpenAiChatSettings() { UseStreaming = false, Number = 1 } }; var memory = new ChatRPGConversationMemory(llm, campaign.GameSummary); - var agent = new ReActAgentChain(llm, memory, _reActPrompt, "", useStreaming: false); - //agent.UseTool(); + var agent = new ReActAgentChain(llm, memory, _reActPrompt, actionPrompt, useStreaming: false); + var tools = CreateTools(campaign); + foreach (var tool in tools) + { + agent.UseTool(tool); + } var chain = Set(input, "input") | agent; var result = await chain.RunAsync("text"); @@ -42,18 +53,22 @@ public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign ca { var agentLlm = new Gpt4Model(_provider) { - Settings = new OpenAiChatSettings() { UseStreaming = true } + Settings = new OpenAiChatSettings() { UseStreaming = true, Number = 1 } }; var memoryLlm = new Gpt4Model(_provider) { - Settings = new OpenAiChatSettings() { UseStreaming = false } + Settings = new OpenAiChatSettings() { UseStreaming = false, Number = 1 } }; var eventProcessor = new LlmEventProcessor(agentLlm); var memory = new ChatRPGConversationMemory(memoryLlm, campaign.GameSummary); - var agent = new ReActAgentChain(agentLlm, memory, _reActPrompt, "", useStreaming: true); - //agent.UseTool(); + var agent = new ReActAgentChain(agentLlm, memory, _reActPrompt, actionPrompt, useStreaming: true); + var tools = CreateTools(campaign); + foreach (var tool in tools) + { + agent.UseTool(tool); + } var chain = Set(input, "input") | agent; @@ -74,12 +89,37 @@ private static void UpdateCampaign(Campaign campaign, ChatRPGConversationMemory campaign.GameSummary = memory.Summary; foreach (var (role, message) in memory.Messages) { - // Only add the message, is the list is empty. + // Only add the message, if the list is empty. // This is because if the list is empty, the input is the initial prompt. Not player input. - if (campaign.Messages.Count != 0) + if (campaign.Messages.Count == 0 && role == MessageRole.User) { - campaign.Messages.Add(new Message(campaign, role, message)); + continue; } + + campaign.Messages.Add(new Message(campaign, role, message.Trim())); } } + + private List CreateTools(Campaign campaign) + { + var tools = new List(); + var utils = new ToolUtilities(_configuration); + + var woundCharacterTool = new WoundCharacterTool(_configuration, campaign, utils, "woundcharactertool", + "This tool must be used when a character will be hurt or wounded resulting from unnoticed attacks" + + "or performing dangerous activities that will lead to injury. The tool is only appropriate if the damage " + + "cannot be mitigated, dodged, or avoided. Example: A character performs a sneak attack " + + "without being spotted by the enemies they try to attack. A dangerous activity could be to threateningly " + + "approach a King, which may result in injury when his guards step forward to stop the character. " + + "Input to this tool must be in the following RAW JSON format: {\"input\": \"The game summary appended with the " + + "player's input\", \"severity\": \"Describes how devastating the injury to the character will be based on " + + "the action. Can be one of the following values: {low, medium, high, extraordinary}}"); + tools.Add(woundCharacterTool); + + // Use battle when an attack can be mitigated or dodged by the involved participants. + // This tool is appropriate for combat, battle between multiple participants, + // or attacks that can be avoided and a to-hit roll would be needed in order to determine a hit. + + return tools; + } } diff --git a/ChatRPG/API/Tools/EffectInput.cs b/ChatRPG/API/Tools/EffectInput.cs new file mode 100644 index 0000000..d2646b7 --- /dev/null +++ b/ChatRPG/API/Tools/EffectInput.cs @@ -0,0 +1,7 @@ +namespace ChatRPG.API.Tools; + +public class EffectInput +{ + public string? Input { get; set; } + public string? Severity { get; set; } +} diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs new file mode 100644 index 0000000..8a448f1 --- /dev/null +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -0,0 +1,75 @@ +using System.Text; +using System.Text.Json; +using ChatRPG.API.Response; +using ChatRPG.Data.Models; +using LangChain.Providers; +using LangChain.Providers.OpenAI; +using LangChain.Providers.OpenAI.Predefined; + +namespace ChatRPG.API.Tools; + +public class ToolUtilities(IConfiguration configuration) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public async Task FindCharacter(Campaign campaign, string input, string instruction) + { + var provider = new OpenAiProvider(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")!); + var llm = new Gpt4Model(provider) + { + Settings = new OpenAiChatSettings() { UseStreaming = false } + }; + + // Add system prompt and construct LLM query + var query = new StringBuilder(); + query.Append(configuration.GetSection("SystemPrompts")?.GetValue("FindCharacter")! + .Replace("{instruction}", instruction)); + + query.Append($"\n\nThe story up until now and the players newest action: {input}"); + + query.Append("\n\nHere is the list of all characters present in the story:\n\n{\n"); + + foreach (var character in campaign.Characters) + { + query.Append( + $"{{\"name\": \"{character.Name}\", \"description\": \"{character.Description}\", \"type\": \"{character.Type}\"}},"); + } + + query.Length--; // Remove last comma + + query.Append("\n}"); + + var response = await llm.GenerateAsync(query.ToString()); + + try + { + var llmResponseCharacter = + JsonSerializer.Deserialize(response.ToString(), JsonOptions); + + if (llmResponseCharacter is null) return null; + + try + { + var character = campaign.Characters + .First(c => c.Name == llmResponseCharacter.Name && + c.Description == llmResponseCharacter.Description && c.Type + .ToString().Equals(llmResponseCharacter.Type, + StringComparison.CurrentCultureIgnoreCase)); + + return character; + } + catch (InvalidOperationException) + { + // The character was not found in the campaign database + return null; + } + } + catch (JsonException) + { + return null; // Format was unexpected + } + } +} diff --git a/ChatRPG/API/Tools/WoundCharacterTool.cs b/ChatRPG/API/Tools/WoundCharacterTool.cs new file mode 100644 index 0000000..69b55bf --- /dev/null +++ b/ChatRPG/API/Tools/WoundCharacterTool.cs @@ -0,0 +1,75 @@ +using ChatRPG.Data.Models; +using LangChain.Chains.StackableChains.Agents.Tools; +using System.Text.Json; +using JsonSerializerOptions = System.Text.Json.JsonSerializerOptions; + + +namespace ChatRPG.API.Tools; + +public class WoundCharacterTool( + IConfiguration configuration, + Campaign campaign, + ToolUtilities utilities, + string name, + string? description = null) : AgentTool(name, description) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly Dictionary DamageRanges = new() + { + { "low", (5, 10) }, + { "medium", (10, 20) }, + { "high", (15, 25) }, + { "extraordinary", (25, 80) } + }; + + public override async Task ToolTask(string input, CancellationToken token = new CancellationToken()) + { + try + { + var effectInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + throw new JsonException("Failed to deserialize"); + + var instruction = configuration.GetSection("SystemPrompts")?.GetValue("WoundCharacterInstruction")!; + var character = await utilities.FindCharacter(campaign, effectInput.Input!, instruction); + + if (character is null) + { + return "Could not determine the character to wound."; + } + + // Determine damage + Random rand = new Random(); + var (minDamage, maxDamage) = DamageRanges[effectInput.Severity!]; + var damage = rand.Next(minDamage, maxDamage); + + if (character.AdjustHealth(-damage)) + { + // Character died + var result = + $"The character {character.Name} is wounded for {damage} damage. They have no remaining health points."; + + if (character.IsPlayer) + { + return result + + $"With no health points remaining, {character.Name} dies and the adventure is over. No more actions can be taken."; + } + + return result + + $"With no health points remaining, {character.Name} dies and can no longer perform actions in the narrative."; + } + + // Character survived + return + $"The character {character.Name} is wounded for {damage} damage. They have {character.CurrentHealth} " + + $"health points out of {character.MaxHealth} remaining."; + } + catch (Exception) + { + return "Could not determine the character to wound"; + } + } +} diff --git a/ChatRPG/ChatRPG.csproj b/ChatRPG/ChatRPG.csproj index 7f6541d..4c5b4c5 100644 --- a/ChatRPG/ChatRPG.csproj +++ b/ChatRPG/ChatRPG.csproj @@ -36,8 +36,4 @@ - - - - diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index a2be169..33bf0a0 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -8,9 +8,4 @@ public async Task SaveCurrentState(Campaign campaign) { await persistenceService.SaveAsync(campaign); } - - public static T ParseToEnum(string input, T defaultVal) where T : struct, Enum - { - return Enum.TryParse(input, true, out T type) ? type : defaultVal; - } } diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 331d000..f389804 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -17,15 +17,17 @@ "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { - "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation: the result of the action ``` When you have a response to say to the Human, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Previous conversation history: {history} The Assistant should take the following into account: {action} New input: {input}", - "Initial": "You are an expert game master in an RPG. You direct the narrative and control non-player characters. The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" } Where \"characters\" includes any new characters met by the player, describing them concisely here in this way: { \"name\": \"Name of the character\", \"description\": \"Short description\", \"type\": \"Humanoid, SmallCreature, LargeCreature or Monster\" }. \"environment\" is filled out when the player enters a new location, describe it shortly here in the format: { \"name\": \"environment\", \"description\": \"short description\" }.", + "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation: the result of the action ``` When you have a response to say to the Player, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Game summary: {history} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input}", + "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", "CombatHitHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" }.", "CombatHitMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack will miss. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", "CombatMissHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done. Your job is to provide flavor text regarding this attack. The player's attack always misses. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", "CombatMissMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done. Your job is to provide flavor text regarding this attack. The player's attack always misses. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack always misses. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", "CombatOpponentDescription": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. The player has just attacked someone. Your job is to determine who the player is attacking. Always respond in valid JSON, and in this exact structure: { \"opponent\": \"name of current opponent\", \"characters\": [] }, where \"characters\" includes whoever the user is attacking if they have not previously appeared in the narrative, describing them concisely here in this exact way: { \"name\": \"Name of the character\", \"description\": \"Short description\", \"type\": \"Humanoid, SmallCreature, LargeCreature or Monster\" }.", "DoActionHurtOrHeal": "You are an expert game master in a single-player RPG. The player has just input an action that they want to perform. Your job is to determine whether the player's action will hurt them, heal them, or both. For example, the player could stab themselves, which would hurt them. The player could also drink a potion or take a short rest, which would heal them. Always respond in valid JSON, and in this exact structure: {\"hurt\": true/false, \"heal\": true/false}.", - "DoAction": "You are an expert game master in an RPG. You direct the narrative and control non-player characters. The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. If the player tries to harm someone else, do not explicitly state whether it was successful or not. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" } Where \"characters\" includes any new characters met by the player, describing them concisely here in this way: { \"name\": \"Name of the character\", \"description\": \"Short description\", \"type\": \"Humanoid, SmallCreature, LargeCreature or Monster\" }. \"environment\" is filled out when the player enters a new location, describe it shortly here in the format: { \"name\": \"environment\", \"description\": \"short description\" }.", - "SayAction": "You are an expert game master in an RPG. You direct the narrative and control non-player characters. The player has input something that they want to say. You must describe how characters react and what they say. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" } Where \"characters\" includes any new characters met by the player, describing them concisely here in this way: { \"name\": \"Name of the character\", \"description\": \"Short description\", \"type\": \"Humanoid, SmallCreature, LargeCreature or Monster\" }. \"environment\" is filled out when the player enters a new location, describe it shortly here in the format: { \"name\": \"environment\", \"description\": \"short description\" }." + "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player in the second person. Always respond in a narrative as the game master in an immersive way.", + "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player in the second person. Always respond in a narrative as the game master in an immersive way.", + "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction}. Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object.", + "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury" } } From d4bf31fba5a0eae6f6e335879cf7708013e36481 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Tue, 22 Oct 2024 13:45:03 +0200 Subject: [PATCH 12/51] Wound character tools appears to work --- ChatRPG/API/{Memory => }/ChatRPGSummarizer.cs | 4 +- ChatRPG/API/LlmEventProcessor.cs | 16 ++--- .../API/Memory/ChatRPGConversationMemory.cs | 63 ------------------- ChatRPG/API/ReActAgentChain.cs | 50 ++++++++++----- ChatRPG/API/ReActLlmClient.cs | 52 ++++++++------- ChatRPG/API/Tools/WoundCharacterTool.cs | 7 +-- ChatRPG/Pages/CampaignPage.razor.cs | 4 +- ChatRPG/Pages/HealthBar.razor | 10 +-- ChatRPG/Program.cs | 1 - ChatRPG/Services/GameInputHandler.cs | 2 - ChatRPG/appsettings.json | 6 +- 11 files changed, 85 insertions(+), 130 deletions(-) rename ChatRPG/API/{Memory => }/ChatRPGSummarizer.cs (82%) delete mode 100644 ChatRPG/API/Memory/ChatRPGConversationMemory.cs diff --git a/ChatRPG/API/Memory/ChatRPGSummarizer.cs b/ChatRPG/API/ChatRPGSummarizer.cs similarity index 82% rename from ChatRPG/API/Memory/ChatRPGSummarizer.cs rename to ChatRPG/API/ChatRPGSummarizer.cs index d2da8db..6cb1b63 100644 --- a/ChatRPG/API/Memory/ChatRPGSummarizer.cs +++ b/ChatRPG/API/ChatRPGSummarizer.cs @@ -2,12 +2,12 @@ using LangChain.Providers; using static LangChain.Chains.Chain; -namespace ChatRPG.API.Memory; +namespace ChatRPG.API; public static class ChatRPGSummarizer { public const string SummaryPrompt = @" -Progressively summarize the interaction between the player and the GM. The player describes their actions in response to the game world, and the GM narrates the outcome, revealing the next part of the adventure. Return a new summary based on each exchange taking into account the previous summary. +Progressively summarize the interaction between the player and the GM. Append to the summary so that new messages are most represented, while still remembering key details of the far past. The player describes their actions in response to the game world, and the GM narrates the outcome, revealing the next part of the adventure. Return a new summary based on each exchange taking into account the previous summary. EXAMPLE Current summary: diff --git a/ChatRPG/API/LlmEventProcessor.cs b/ChatRPG/API/LlmEventProcessor.cs index 2b78ba0..9e46aa7 100644 --- a/ChatRPG/API/LlmEventProcessor.cs +++ b/ChatRPG/API/LlmEventProcessor.cs @@ -35,7 +35,7 @@ private void OnDeltaReceived(object? sender, ChatResponseDelta delta) { if (_foundFinalAnswer) { - // Directly output content after "Final answer: " has been detected + // Directly output content after "Final Answer: " has been detected _channel.Writer.TryWrite(delta.Content); } else @@ -43,19 +43,19 @@ private void OnDeltaReceived(object? sender, ChatResponseDelta delta) // Accumulate the content in the buffer _buffer.Append(delta.Content); - // Check if the buffer contains "Final answer: " + // Check if the buffer contains "Final Answer: " var bufferString = _buffer.ToString(); - int finalAnswerIndex = bufferString.IndexOf("Final Answer: ", StringComparison.Ordinal); + int finalAnswerIndex = bufferString.IndexOf("Final Answer:", StringComparison.Ordinal); if (finalAnswerIndex != -1) { - // Output everything after "Final answer: " has been detected - int startOutputIndex = finalAnswerIndex + "Final Answer: ".Length; + // Output everything after "Final Answer: " has been detected + int startOutputIndex = finalAnswerIndex + "Final Answer:".Length; // Switch to streaming mode _foundFinalAnswer = true; - // Output any content after "Final answer: " + // Output any content after "Final Answer: " _channel.Writer.TryWrite(bufferString[startOutputIndex..]); // Clear the buffer since it's no longer needed @@ -69,10 +69,12 @@ private void OnResponseReceived(object? sender, ChatResponse response) { lock (_lock) { + _buffer.Clear(); // Clear buffer to avoid carrying over any previous data + if (!_foundFinalAnswer) return; + // Reset the state so that the process can start over _foundFinalAnswer = false; _channel.Writer.TryComplete(); - _buffer.Clear(); // Clear buffer to avoid carrying over any previous data } } } diff --git a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs b/ChatRPG/API/Memory/ChatRPGConversationMemory.cs deleted file mode 100644 index b035c82..0000000 --- a/ChatRPG/API/Memory/ChatRPGConversationMemory.cs +++ /dev/null @@ -1,63 +0,0 @@ -using LangChain.Memory; -using LangChain.Providers; -using LangChain.Schema; -using Message = LangChain.Providers.Message; -using MessageRole = LangChain.Providers.MessageRole; - -namespace ChatRPG.API.Memory; - -public class ChatRPGConversationMemory(IChatModel model, string? summary) : BaseChatMemory -{ - private IChatModel Model { get; } = model ?? throw new ArgumentNullException(nameof(model)); - public string? Summary { get; private set; } = summary; - public readonly List<(Data.Models.MessageRole, string)> Messages = new(); - - public string MemoryKey { get; set; } = "history"; - public override List MemoryVariables => [MemoryKey]; - - public override OutputValues LoadMemoryVariables(InputValues? inputValues) - { - return new OutputValues(new Dictionary { { MemoryKey, Summary ?? "" } }); - } - - public override async Task SaveContext(InputValues inputValues, OutputValues outputValues) - { - inputValues = inputValues ?? throw new ArgumentNullException(nameof(inputValues)); - outputValues = outputValues ?? throw new ArgumentNullException(nameof(outputValues)); - - var newMessages = new List(); - - // If the InputKey is not specified, there must only be one input value - var inputKey = InputKey ?? inputValues.Value.Keys.Single(); - - var humanMessageContent = inputValues.Value[inputKey].ToString() ?? string.Empty; - newMessages.Add(new Message(humanMessageContent, MessageRole.Human)); - - Messages.Add((Data.Models.MessageRole.User, humanMessageContent)); - - // If the OutputKey is not specified, there must only be one output value - var outputKey = OutputKey ?? outputValues.Value.Keys.Single(); - - var aiMessageContent = outputValues.Value[outputKey].ToString() ?? string.Empty; - int finalAnswerIndex = aiMessageContent.IndexOf("Final Answer: ", StringComparison.Ordinal); - if (finalAnswerIndex != -1) - { - // Only keep final answer - int startOutputIndex = finalAnswerIndex + "Final Answer: ".Length; - aiMessageContent = aiMessageContent[startOutputIndex..]; - } - - newMessages.Add(new Message(aiMessageContent, MessageRole.Ai)); - - Messages.Add((Data.Models.MessageRole.Assistant, aiMessageContent)); - - Summary = await Model.SummarizeAsync(newMessages, Summary ?? "").ConfigureAwait(true); - - await base.SaveContext(inputValues, outputValues).ConfigureAwait(false); - } - - public override async Task Clear() - { - await base.Clear().ConfigureAwait(false); - } -} diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index ffef5fa..4e431b6 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -13,16 +13,18 @@ namespace ChatRPG.API; public sealed class ReActAgentChain : BaseStackableChain { private const string ReActAnswer = "answer"; - private readonly BaseChatMemory _conversationSummaryMemory; + private readonly ConversationBufferMemory _conversationBufferMemory; + private readonly ChatMessageHistory _chatMessageHistory; + private readonly MessageFormatter _messageFormatter; private readonly int _maxActions; private readonly IChatModel _model; private readonly string _reActPrompt; private readonly string _actionPrompt; - private readonly bool _useStreaming; private StackChain? _chain; private readonly Dictionary _tools = new(); private bool _useCache; private string _userInput = string.Empty; + private readonly string _gameSummary; public string DefaultPrompt = @"Assistant is a large language model trained by OpenAI. @@ -62,6 +64,9 @@ Always add [END] after final answer Begin! +Game summary: +{summary} + Previous conversation history: {history} @@ -69,24 +74,40 @@ Always add [END] after final answer public ReActAgentChain( IChatModel model, - BaseChatMemory memory, string? reActPrompt = null, string? actionPrompt = null, + string? gameSummary = null, string inputKey = "input", string outputKey = "text", - int maxActions = 10, - bool useStreaming = true) + int maxActions = 10) { _model = model; + _model.Settings!.StopSequences = ["Observation", "[END]"]; _reActPrompt = reActPrompt ?? DefaultPrompt; _actionPrompt = actionPrompt ?? string.Empty; _maxActions = maxActions; + _gameSummary = gameSummary ?? string.Empty; InputKeys = [inputKey]; OutputKeys = [outputKey]; - _useStreaming = useStreaming; - _conversationSummaryMemory = memory; + _messageFormatter = new MessageFormatter + { + AiPrefix = "", + HumanPrefix = "", + SystemPrefix = "" + }; + + _chatMessageHistory = new ChatMessageHistory() + { + // Do not save human messages + IsMessageAccepted = x => (x.Role != MessageRole.Human) + }; + + _conversationBufferMemory = new ConversationBufferMemory(_chatMessageHistory) + { + Formatter = _messageFormatter + }; } private void InitializeChain() @@ -99,14 +120,11 @@ private void InitializeChain() | Set(tools, "tools") | Set(toolNames, "tool_names") | Set(_actionPrompt, "action") - | LoadMemory(_conversationSummaryMemory, "history") + | Set(_gameSummary, "summary") + | LoadMemory(_conversationBufferMemory, "history") | Template(_reActPrompt) - | LLM(_model, settings: new ChatSettings - { - StopSequences = ["Observation", "[END]"], - UseStreaming = _useStreaming - }).UseCache(_useCache) - | UpdateMemory(_conversationSummaryMemory, "input", "text") + | LLM(_model).UseCache(_useCache) + | UpdateMemory(_conversationBufferMemory, "input", "text") | ReActParser("text", ReActAnswer); @@ -151,10 +169,10 @@ protected override async Task InternalCallAsync(IChainValues value var action = (AgentAction)res.Value[ReActAnswer]; var tool = _tools[action.Action.ToLower(CultureInfo.InvariantCulture)]; var toolRes = await tool.ToolTask(action.ActionInput, cancellationToken).ConfigureAwait(false); - await _conversationSummaryMemory.ChatHistory + await _conversationBufferMemory.ChatHistory .AddMessage(new Message("Observation: " + toolRes, MessageRole.System)) .ConfigureAwait(false); - await _conversationSummaryMemory.ChatHistory.AddMessage(new Message("Thought:", MessageRole.System)) + await _conversationBufferMemory.ChatHistory.AddMessage(new Message("Thought:", MessageRole.System)) .ConfigureAwait(false); break; case AgentFinish: diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 1f7afb2..89ba24c 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -1,5 +1,4 @@ -using Anthropic; -using ChatRPG.API.Memory; +using System.Text; using ChatRPG.API.Tools; using ChatRPG.Data.Models; using LangChain.Chains.StackableChains.Agents.Tools; @@ -30,10 +29,9 @@ public async Task GetChatCompletionAsync(Campaign campaign, string actio { var llm = new Gpt4Model(_provider) { - Settings = new OpenAiChatSettings() { UseStreaming = false, Number = 1 } + Settings = new OpenAiChatSettings() { UseStreaming = false } }; - var memory = new ChatRPGConversationMemory(llm, campaign.GameSummary); - var agent = new ReActAgentChain(llm, memory, _reActPrompt, actionPrompt, useStreaming: false); + var agent = new ReActAgentChain(llm, _reActPrompt, actionPrompt, campaign.GameSummary); var tools = CreateTools(campaign); foreach (var tool in tools) { @@ -43,7 +41,7 @@ public async Task GetChatCompletionAsync(Campaign campaign, string actio var chain = Set(input, "input") | agent; var result = await chain.RunAsync("text"); - UpdateCampaign(campaign, memory); + await UpdateCampaign(campaign, input, result!); return result!; } @@ -53,17 +51,11 @@ public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign ca { var agentLlm = new Gpt4Model(_provider) { - Settings = new OpenAiChatSettings() { UseStreaming = true, Number = 1 } - }; - - var memoryLlm = new Gpt4Model(_provider) - { - Settings = new OpenAiChatSettings() { UseStreaming = false, Number = 1 } + Settings = new OpenAiChatSettings() { UseStreaming = true, Temperature = 0.7, } }; var eventProcessor = new LlmEventProcessor(agentLlm); - var memory = new ChatRPGConversationMemory(memoryLlm, campaign.GameSummary); - var agent = new ReActAgentChain(agentLlm, memory, _reActPrompt, actionPrompt, useStreaming: true); + var agent = new ReActAgentChain(agentLlm, _reActPrompt, actionPrompt, campaign.GameSummary); var tools = CreateTools(campaign); foreach (var tool in tools) { @@ -72,31 +64,44 @@ public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign ca var chain = Set(input, "input") | agent; - var result = chain.RunAsync(); + var response = chain.RunAsync("text"); await foreach (var content in eventProcessor.GetContentStreamAsync()) { yield return content; } - await result; + var result = await response; - UpdateCampaign(campaign, memory); + await UpdateCampaign(campaign, input, result!); } - private static void UpdateCampaign(Campaign campaign, ChatRPGConversationMemory memory) + private async Task UpdateCampaign(Campaign campaign, string playerInput, string assistantOutput) { - campaign.GameSummary = memory.Summary; - foreach (var (role, message) in memory.Messages) + var newMessages = new List + { + new(playerInput, LangChain.Providers.MessageRole.Human), + new(assistantOutput, LangChain.Providers.MessageRole.Ai) + }; + + var summaryLlm = new Gpt4Model(_provider) + { + Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } + }; + + campaign.GameSummary = await summaryLlm.SummarizeAsync(newMessages, campaign.GameSummary ?? ""); + foreach (var message in newMessages) { // Only add the message, if the list is empty. // This is because if the list is empty, the input is the initial prompt. Not player input. - if (campaign.Messages.Count == 0 && role == MessageRole.User) + if (campaign.Messages.Count == 0 && message.Role == LangChain.Providers.MessageRole.Human) { continue; } - campaign.Messages.Add(new Message(campaign, role, message.Trim())); + campaign.Messages.Add(new Message(campaign, + (message.Role == LangChain.Providers.MessageRole.Human ? MessageRole.User : MessageRole.Assistant), + message.Content.Trim())); } } @@ -113,7 +118,8 @@ private List CreateTools(Campaign campaign) "approach a King, which may result in injury when his guards step forward to stop the character. " + "Input to this tool must be in the following RAW JSON format: {\"input\": \"The game summary appended with the " + "player's input\", \"severity\": \"Describes how devastating the injury to the character will be based on " + - "the action. Can be one of the following values: {low, medium, high, extraordinary}}"); + "the action. Can be one of the following values: {low, medium, high, extraordinary}}\". Do not use markdown, " + + "only raw JSON as input. Use this tool only once per character at most."); tools.Add(woundCharacterTool); // Use battle when an attack can be mitigated or dodged by the involved participants. diff --git a/ChatRPG/API/Tools/WoundCharacterTool.cs b/ChatRPG/API/Tools/WoundCharacterTool.cs index 69b55bf..494c3fb 100644 --- a/ChatRPG/API/Tools/WoundCharacterTool.cs +++ b/ChatRPG/API/Tools/WoundCharacterTool.cs @@ -3,7 +3,6 @@ using System.Text.Json; using JsonSerializerOptions = System.Text.Json.JsonSerializerOptions; - namespace ChatRPG.API.Tools; public class WoundCharacterTool( @@ -33,7 +32,7 @@ public class WoundCharacterTool( var effectInput = JsonSerializer.Deserialize(input, JsonOptions) ?? throw new JsonException("Failed to deserialize"); - var instruction = configuration.GetSection("SystemPrompts")?.GetValue("WoundCharacterInstruction")!; + var instruction = configuration.GetSection("SystemPrompts").GetValue("WoundCharacterInstruction")!; var character = await utilities.FindCharacter(campaign, effectInput.Input!, instruction); if (character is null) @@ -55,11 +54,11 @@ public class WoundCharacterTool( if (character.IsPlayer) { return result + - $"With no health points remaining, {character.Name} dies and the adventure is over. No more actions can be taken."; + $" With no health points remaining, {character.Name} dies and the adventure is over. No more actions can be taken."; } return result + - $"With no health points remaining, {character.Name} dies and can no longer perform actions in the narrative."; + $" With no health points remaining, {character.Name} dies and can no longer perform actions in the narrative."; } // Character survived diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 3e4e23c..6a410e1 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -145,9 +145,9 @@ private async Task SendPrompt() OpenAiGptMessage userInput = new(MessageRole.User, _userInput, _activeUserPromptType); _conversation.Add(userInput); _latestPlayerMessage = userInput; - await ScrollToElement(BottomId); - await GameInputHandler!.HandleUserPrompt(_campaign, _activeUserPromptType, _userInput); _userInput = string.Empty; + await ScrollToElement(BottomId); + await GameInputHandler!.HandleUserPrompt(_campaign, _activeUserPromptType, userInput.Content); _conversation.RemoveAll(m => m.Role.Equals(MessageRole.System)); UpdateStatsUi(); } diff --git a/ChatRPG/Pages/HealthBar.razor b/ChatRPG/Pages/HealthBar.razor index e5351a6..99b1990 100644 --- a/ChatRPG/Pages/HealthBar.razor +++ b/ChatRPG/Pages/HealthBar.razor @@ -9,13 +9,9 @@ @code { - [Parameter] - [EditorRequired] - public int? MaxHealth { get; set; } + [Parameter] [EditorRequired] public int? MaxHealth { get; set; } - [Parameter] - [EditorRequired] - public int? CurrentHealth { get; set; } + [Parameter] [EditorRequired] public int? CurrentHealth { get; set; } - private double? Percentage => (double?) CurrentHealth / MaxHealth * 100; + private int? Percentage => (int?)((double?)CurrentHealth / MaxHealth * 100); } diff --git a/ChatRPG/Program.cs b/ChatRPG/Program.cs index 98b2030..9b9af33 100644 --- a/ChatRPG/Program.cs +++ b/ChatRPG/Program.cs @@ -1,6 +1,5 @@ using Blazored.Modal; using ChatRPG.API; -using ChatRPG.API.Memory; using ChatRPG.Areas.Identity; using ChatRPG.Data; using ChatRPG.Data.Models; diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index f50fbd3..d8b9e95 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -1,9 +1,7 @@ using ChatRPG.API; -using ChatRPG.API.Response; using ChatRPG.Data.Models; using ChatRPG.Pages; using ChatRPG.Services.Events; -using Environment = ChatRPG.Data.Models.Environment; namespace ChatRPG.Services; diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index f389804..b46dafe 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -17,7 +17,7 @@ "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { - "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation: the result of the action ``` When you have a response to say to the Player, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Game summary: {history} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input}", + "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} Previous tool steps: {history} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", "CombatHitHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" }.", "CombatHitMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack will miss. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", @@ -25,8 +25,8 @@ "CombatMissMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done. Your job is to provide flavor text regarding this attack. The player's attack always misses. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack always misses. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", "CombatOpponentDescription": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. The player has just attacked someone. Your job is to determine who the player is attacking. Always respond in valid JSON, and in this exact structure: { \"opponent\": \"name of current opponent\", \"characters\": [] }, where \"characters\" includes whoever the user is attacking if they have not previously appeared in the narrative, describing them concisely here in this exact way: { \"name\": \"Name of the character\", \"description\": \"Short description\", \"type\": \"Humanoid, SmallCreature, LargeCreature or Monster\" }.", "DoActionHurtOrHeal": "You are an expert game master in a single-player RPG. The player has just input an action that they want to perform. Your job is to determine whether the player's action will hurt them, heal them, or both. For example, the player could stab themselves, which would hurt them. The player could also drink a potion or take a short rest, which would heal them. Always respond in valid JSON, and in this exact structure: {\"hurt\": true/false, \"heal\": true/false}.", - "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player in the second person. Always respond in a narrative as the game master in an immersive way.", - "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player in the second person. Always respond in a narrative as the game master in an immersive way.", + "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", + "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction}. Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object.", "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury" } From dd641b0431202653a61b7fc96a9d7b377841b2b7 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 28 Oct 2024 14:20:40 +0100 Subject: [PATCH 13/51] Disable Inconsistent Naming in ChatRPGSummazier --- ChatRPG/API/ChatRPGSummarizer.cs | 1 + ChatRPG/API/ReActLlmClient.cs | 9 ++++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ChatRPG/API/ChatRPGSummarizer.cs b/ChatRPG/API/ChatRPGSummarizer.cs index 6cb1b63..f23a085 100644 --- a/ChatRPG/API/ChatRPGSummarizer.cs +++ b/ChatRPG/API/ChatRPGSummarizer.cs @@ -4,6 +4,7 @@ namespace ChatRPG.API; +// ReSharper disable once InconsistentNaming public static class ChatRPGSummarizer { public const string SummaryPrompt = @" diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 89ba24c..9fd6be9 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -1,4 +1,3 @@ -using System.Text; using ChatRPG.API.Tools; using ChatRPG.Data.Models; using LangChain.Chains.StackableChains.Agents.Tools; @@ -18,11 +17,11 @@ public class ReActLlmClient : IReActLlmClient public ReActLlmClient(IConfiguration configuration) { - ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")); - ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts")?.GetValue("ReAct")); + ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("ApiKeys").GetValue("OpenAI")); + ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts").GetValue("ReAct")); _configuration = configuration; - _reActPrompt = _configuration.GetSection("SystemPrompts")?.GetValue("ReAct")!; - _provider = new OpenAiProvider(_configuration.GetSection("ApiKeys")?.GetValue("OpenAI")!); + _reActPrompt = _configuration.GetSection("SystemPrompts").GetValue("ReAct")!; + _provider = new OpenAiProvider(_configuration.GetSection("ApiKeys").GetValue("OpenAI")!); } public async Task GetChatCompletionAsync(Campaign campaign, string actionPrompt, string input) From 5a59571e7f7cfe18bbcf95d11548d79b9d381672 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 28 Oct 2024 14:34:20 +0100 Subject: [PATCH 14/51] Remove unused classes --- ChatRPG/API/HttpClientFactory.cs | 16 ------- ChatRPG/API/HttpMessageHandlerFactory.cs | 59 ------------------------ ChatRPG/API/IOpenAiLlmClient.cs | 7 --- ChatRPG/API/OpenAiLlmClient.cs | 53 --------------------- ChatRPG/Program.cs | 3 -- 5 files changed, 138 deletions(-) delete mode 100644 ChatRPG/API/HttpClientFactory.cs delete mode 100644 ChatRPG/API/HttpMessageHandlerFactory.cs delete mode 100644 ChatRPG/API/IOpenAiLlmClient.cs delete mode 100644 ChatRPG/API/OpenAiLlmClient.cs diff --git a/ChatRPG/API/HttpClientFactory.cs b/ChatRPG/API/HttpClientFactory.cs deleted file mode 100644 index dc55acb..0000000 --- a/ChatRPG/API/HttpClientFactory.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace ChatRPG.API; - -public class HttpClientFactory : IHttpClientFactory -{ - private HttpMessageHandlerFactory _httpMessageHandlerFactory; - - public HttpClientFactory(HttpMessageHandlerFactory httpMessageHandlerFactory) - { - _httpMessageHandlerFactory = httpMessageHandlerFactory; - } - - public HttpClient CreateClient(string name) - { - return new HttpClient(_httpMessageHandlerFactory.CreateHandler()); - } -} diff --git a/ChatRPG/API/HttpMessageHandlerFactory.cs b/ChatRPG/API/HttpMessageHandlerFactory.cs deleted file mode 100644 index 4d874d5..0000000 --- a/ChatRPG/API/HttpMessageHandlerFactory.cs +++ /dev/null @@ -1,59 +0,0 @@ -using RichardSzalay.MockHttp; - -namespace ChatRPG.API; - -public class HttpMessageHandlerFactory : IHttpMessageHandlerFactory -{ - private bool _useMocks; - - public HttpMessageHandlerFactory(IConfiguration configuration) - { - _useMocks = configuration.GetValue("UseMocks"); - } - - public HttpMessageHandler CreateHandler(string name) - { - if (!_useMocks) - { - return new HttpClientHandler(); - } - - MockHttpMessageHandler messageHandler = new MockHttpMessageHandler(); - messageHandler.When("*") - .Respond(GenerateMockResponse); - - return messageHandler; - } - - private static HttpResponseMessage GenerateMockResponse(HttpRequestMessage request) - { - Console.Write("Please enter mocked API response: "); - string? input = Console.ReadLine(); - StringContent responseContent = new StringContent($$""" - { - "id": "chatcmpl-000", - "object": "chat.completion", - "created": {{(int)(DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1)).TotalSeconds)}}, - "model": "gpt-3.5-turbo", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "{{input}}" - }, - "finish_reason": "stop" - }], - "usage": { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0 - } - } - """); - - return new HttpResponseMessage - { - Content = responseContent - }; - } -} diff --git a/ChatRPG/API/IOpenAiLlmClient.cs b/ChatRPG/API/IOpenAiLlmClient.cs deleted file mode 100644 index 1d7d85b..0000000 --- a/ChatRPG/API/IOpenAiLlmClient.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ChatRPG.API; - -public interface IOpenAiLlmClient -{ - Task GetChatCompletion(IList inputs, string systemPrompt); - IAsyncEnumerable GetStreamedChatCompletion(IList inputs, string systemPrompt); -} \ No newline at end of file diff --git a/ChatRPG/API/OpenAiLlmClient.cs b/ChatRPG/API/OpenAiLlmClient.cs deleted file mode 100644 index 5e5ecf5..0000000 --- a/ChatRPG/API/OpenAiLlmClient.cs +++ /dev/null @@ -1,53 +0,0 @@ -/*using Microsoft.IdentityModel.Tokens; -using OpenAI_API; -using OpenAI_API.Chat; - -namespace ChatRPG.API; - -public class OpenAiLlmClient : IOpenAiLlmClient -{ - private const string Model = "gpt-4"; - private const double Temperature = 0.7; - - private readonly OpenAIAPI _openAiApi; - - public OpenAiLlmClient(IConfiguration configuration, IHttpClientFactory httpClientFactory) - { - _openAiApi = new OpenAIAPI(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")); - _openAiApi.Chat.DefaultChatRequestArgs.Model = Model; - _openAiApi.Chat.DefaultChatRequestArgs.Temperature = Temperature; - _openAiApi.HttpClientFactory = httpClientFactory; - } - - public async Task GetChatCompletion(IList inputs, string systemPrompt) - { - Conversation chat = CreateConversation(inputs, systemPrompt); - - return await chat.GetResponseFromChatbotAsync(); - } - - public IAsyncEnumerable GetStreamedChatCompletion(IList inputs, string systemPrompt) - { - Conversation chat = CreateConversation(inputs, systemPrompt); - - return chat.StreamResponseEnumerableFromChatbotAsync(); - } - - private Conversation CreateConversation(IList messages, string systemPrompt) - { - if (messages.IsNullOrEmpty()) throw new ArgumentNullException(nameof(messages)); - - Conversation chat = _openAiApi.Chat.CreateConversation(); - if (!string.IsNullOrEmpty(systemPrompt)) - { - chat.AppendSystemMessage(systemPrompt); - } - foreach (OpenAiGptMessage openAiGptInputMessage in messages) - { - chat.AppendMessage(openAiGptInputMessage.Role, openAiGptInputMessage.Content); - } - - return chat; - } -} -*/ diff --git a/ChatRPG/Program.cs b/ChatRPG/Program.cs index 9b9af33..76d3a83 100644 --- a/ChatRPG/Program.cs +++ b/ChatRPG/Program.cs @@ -25,10 +25,7 @@ builder.Services.AddServerSideBlazor(); builder.Services.AddBlazoredModal(); -HttpMessageHandlerFactory httpMessageHandlerFactory = new HttpMessageHandlerFactory(configuration); builder.Services.AddScoped>() - .AddSingleton(httpMessageHandlerFactory) - .AddSingleton() .AddTransient() .AddScoped() .AddTransient() From 2e1c908818b040a5637bd624518d2ad627543662 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 28 Oct 2024 14:56:47 +0100 Subject: [PATCH 15/51] Add more specific answers from WouldCharacterTool --- ChatRPG/API/Tools/WoundCharacterTool.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ChatRPG/API/Tools/WoundCharacterTool.cs b/ChatRPG/API/Tools/WoundCharacterTool.cs index 494c3fb..21766e0 100644 --- a/ChatRPG/API/Tools/WoundCharacterTool.cs +++ b/ChatRPG/API/Tools/WoundCharacterTool.cs @@ -37,7 +37,8 @@ public class WoundCharacterTool( if (character is null) { - return "Could not determine the character to wound."; + return "Could not determine the character to wound. The character does not exist in the game. " + + "Consider creating the character before wounding it."; } // Determine damage @@ -68,7 +69,8 @@ public class WoundCharacterTool( } catch (Exception) { - return "Could not determine the character to wound"; + return "Could not determine the character to wound. Tool input format was invalid. " + + "Please provide a valid character name, description, and severity level in valid JSON without markdown."; } } } From a27edfdc0681e0714241d4a06f35aecaad662e22 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 28 Oct 2024 16:01:00 +0100 Subject: [PATCH 16/51] HealCharacterTools has been created --- ChatRPG/API/ReActLlmClient.cs | 13 ++++ ChatRPG/API/Tools/HealCharacterTool.cs | 59 +++++++++++++++++++ ChatRPG/API/Tools/HealInput.cs | 7 +++ ChatRPG/API/Tools/WoundCharacterTool.cs | 2 +- .../Tools/{EffectInput.cs => WoundInput.cs} | 2 +- ChatRPG/appsettings.json | 3 +- 6 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 ChatRPG/API/Tools/HealCharacterTool.cs create mode 100644 ChatRPG/API/Tools/HealInput.cs rename ChatRPG/API/Tools/{EffectInput.cs => WoundInput.cs} (82%) diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 9fd6be9..7ddaa78 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -121,6 +121,19 @@ private List CreateTools(Campaign campaign) "only raw JSON as input. Use this tool only once per character at most."); tools.Add(woundCharacterTool); + var healCharacterTool = new HealCharacterTool(_configuration, campaign, utils, "healcharactertool", + "This tool must be used when a character performs an action that could heal or restore them to " + + "health after being wounded. The tool is only appropriate if the healing can be done without any " + + "further actions. Example: A character is wounded by an enemy attack and the player decides to heal " + + "the character. Another example would be a scenario where a character consumes a beneficial item like " + + "a potion, a magical item, or spends time in an area that could provide healing " + + "benefits. Resting may provide modest healing effects depending on the duration of the rest. " + + "Input to this tool must be in the following RAW JSON format: {\"input\": \"The game " + + "summary appended with the player's input\", \"magnitude\": \"Describes how much health the character will " + + "regain based on the action. Can be one of the following values: {low, medium, high, extraordinary}}\". " + + "Do not use markdown, only raw JSON as input. Use this tool only once per character at most."); + tools.Add(healCharacterTool); + // Use battle when an attack can be mitigated or dodged by the involved participants. // This tool is appropriate for combat, battle between multiple participants, // or attacks that can be avoided and a to-hit roll would be needed in order to determine a hit. diff --git a/ChatRPG/API/Tools/HealCharacterTool.cs b/ChatRPG/API/Tools/HealCharacterTool.cs new file mode 100644 index 0000000..feceb85 --- /dev/null +++ b/ChatRPG/API/Tools/HealCharacterTool.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using ChatRPG.Data.Models; +using LangChain.Chains.StackableChains.Agents.Tools; + +namespace ChatRPG.API.Tools; + +public class HealCharacterTool( + IConfiguration configuration, + Campaign campaign, + ToolUtilities utilities, + string name, + string? description = null) : AgentTool(name, description) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly Dictionary HealingRanges = new() + { + { "low", (5, 10) }, + { "medium", (10, 20) }, + { "high", (15, 25) }, + { "extraordinary", (25, 80) } + }; + + public override async Task ToolTask(string input, CancellationToken token = new CancellationToken()) + { + try + { + var effectInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + throw new JsonException("Failed to deserialize"); + + var instruction = configuration.GetSection("SystemPrompts").GetValue("HealCharacterInstruction")!; + var character = await utilities.FindCharacter(campaign, effectInput.Input!, instruction); + + if (character is null) + { + return "Could not determine the character to heal. The character does not exist in the game. " + + "Consider creating the character before wounding it."; + } + + // Determine damage + Random rand = new Random(); + var (minHealing, maxHealing) = HealingRanges[effectInput.Magnitude!]; + var healing = rand.Next(minHealing, maxHealing); + + character.AdjustHealth(healing); + + return $"The character {character.Name} is healed for {healing} health points. They now have {character.CurrentHealth} health points out of a total of {character.MaxHealth}."; + + } + catch (Exception) + { + return "Could not determine the character to heal. Tool input format was invalid. " + + "Please provide a valid character name, description, and magnitude level in valid JSON without markdown."; + } + } +} diff --git a/ChatRPG/API/Tools/HealInput.cs b/ChatRPG/API/Tools/HealInput.cs new file mode 100644 index 0000000..fd10444 --- /dev/null +++ b/ChatRPG/API/Tools/HealInput.cs @@ -0,0 +1,7 @@ +namespace ChatRPG.API.Tools; + +public class HealInput +{ + public string? Input { get; set; } + public string? Magnitude { get; set; } +} diff --git a/ChatRPG/API/Tools/WoundCharacterTool.cs b/ChatRPG/API/Tools/WoundCharacterTool.cs index 21766e0..3880dfc 100644 --- a/ChatRPG/API/Tools/WoundCharacterTool.cs +++ b/ChatRPG/API/Tools/WoundCharacterTool.cs @@ -29,7 +29,7 @@ public class WoundCharacterTool( { try { - var effectInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var effectInput = JsonSerializer.Deserialize(input, JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("WoundCharacterInstruction")!; diff --git a/ChatRPG/API/Tools/EffectInput.cs b/ChatRPG/API/Tools/WoundInput.cs similarity index 82% rename from ChatRPG/API/Tools/EffectInput.cs rename to ChatRPG/API/Tools/WoundInput.cs index d2646b7..28eb6a9 100644 --- a/ChatRPG/API/Tools/EffectInput.cs +++ b/ChatRPG/API/Tools/WoundInput.cs @@ -1,6 +1,6 @@ namespace ChatRPG.API.Tools; -public class EffectInput +public class WoundInput { public string? Input { get; set; } public string? Severity { get; set; } diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index b46dafe..a782239 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -28,6 +28,7 @@ "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction}. Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object.", - "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury" + "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury", + "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, or through consuming a health potion, or by resting" } } From b7619fd80e4e5550865f1338a7b1d56eec4b7108 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 28 Oct 2024 16:04:18 +0100 Subject: [PATCH 17/51] Change wound to heal in tool answer in case of character not found --- ChatRPG/API/Tools/HealCharacterTool.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChatRPG/API/Tools/HealCharacterTool.cs b/ChatRPG/API/Tools/HealCharacterTool.cs index feceb85..4b3c1de 100644 --- a/ChatRPG/API/Tools/HealCharacterTool.cs +++ b/ChatRPG/API/Tools/HealCharacterTool.cs @@ -37,7 +37,7 @@ public class HealCharacterTool( if (character is null) { return "Could not determine the character to heal. The character does not exist in the game. " + - "Consider creating the character before wounding it."; + "Consider creating the character before healing it."; } // Determine damage From a465ab8a30e41718685b746c22ac2b78da7c6bb0 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Tue, 29 Oct 2024 15:06:31 +0100 Subject: [PATCH 18/51] UpdateCharacterTool and archive chain --- ChatRPG/API/ChatRPGSummarizer.cs | 2 +- ChatRPG/API/ReActAgentChain.cs | 77 +++++++++++--- ChatRPG/API/ReActLlmClient.cs | 75 +++++--------- ChatRPG/API/Tools/HealCharacterTool.cs | 6 +- ChatRPG/API/Tools/NarrativeTool.cs | 28 ++++++ ChatRPG/API/Tools/ToolUtilities.cs | 8 +- ChatRPG/API/Tools/UpdateCharacterInput.cs | 9 ++ ChatRPG/API/Tools/UpdateCharacterTool.cs | 93 +++++++++++++++++ ChatRPG/Pages/CampaignPage.razor | 1 - ChatRPG/Pages/CampaignPage.razor.cs | 8 +- ChatRPG/Pages/UserPromptType.cs | 4 +- ChatRPG/Services/GameInputHandler.cs | 7 +- ChatRPG/Services/GameStateManager.cs | 117 +++++++++++++++++++++- ChatRPG/appsettings.json | 6 +- 14 files changed, 351 insertions(+), 90 deletions(-) create mode 100644 ChatRPG/API/Tools/NarrativeTool.cs create mode 100644 ChatRPG/API/Tools/UpdateCharacterInput.cs create mode 100644 ChatRPG/API/Tools/UpdateCharacterTool.cs diff --git a/ChatRPG/API/ChatRPGSummarizer.cs b/ChatRPG/API/ChatRPGSummarizer.cs index f23a085..37a7a78 100644 --- a/ChatRPG/API/ChatRPGSummarizer.cs +++ b/ChatRPG/API/ChatRPGSummarizer.cs @@ -8,7 +8,7 @@ namespace ChatRPG.API; public static class ChatRPGSummarizer { public const string SummaryPrompt = @" -Progressively summarize the interaction between the player and the GM. Append to the summary so that new messages are most represented, while still remembering key details of the far past. The player describes their actions in response to the game world, and the GM narrates the outcome, revealing the next part of the adventure. Return a new summary based on each exchange taking into account the previous summary. +Progressively summarize the interaction between the player and the GM. Append to the summary so that new messages are most represented, while still remembering key details of the far past. The player describes their actions in response to the game world, and the GM narrates the outcome, revealing the next part of the adventure. Return a new summary based on each exchange taking into account the previous summary. Expand the summary so that it is long enough to capture the essence of the story from the beginning without forgetting key details. Do not remove important people, events, or locations from the summary. EXAMPLE Current summary: diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index 4e431b6..2f13bf7 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -25,6 +25,8 @@ public sealed class ReActAgentChain : BaseStackableChain private bool _useCache; private string _userInput = string.Empty; private readonly string _gameSummary; + private string _characters = string.Empty; + private string _environments = string.Empty; public string DefaultPrompt = @"Assistant is a large language model trained by OpenAI. @@ -75,7 +77,6 @@ Always add [END] after final answer public ReActAgentChain( IChatModel model, string? reActPrompt = null, - string? actionPrompt = null, string? gameSummary = null, string inputKey = "input", string outputKey = "text", @@ -84,7 +85,6 @@ public ReActAgentChain( _model = model; _model.Settings!.StopSequences = ["Observation", "[END]"]; _reActPrompt = reActPrompt ?? DefaultPrompt; - _actionPrompt = actionPrompt ?? string.Empty; _maxActions = maxActions; _gameSummary = gameSummary ?? string.Empty; @@ -110,25 +110,72 @@ public ReActAgentChain( }; } + public ReActAgentChain( + IChatModel model, + string reActPrompt, + string? actionPrompt = null, + string? gameSummary = null, + string inputKey = "input", + string outputKey = "text", + int maxActions = 10) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) + { + _actionPrompt = actionPrompt ?? string.Empty; + } + + public ReActAgentChain( + IChatModel model, + string reActPrompt, + string characters, + string environments, + string? gameSummary = null, + string inputKey = "input", + string outputKey = "text", + int maxActions = 10) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) + { + _characters = characters; + _environments = environments; + } + + private void InitializeChain() { var toolNames = string.Join(",", _tools.Select(x => x.Key)); var tools = string.Join("\n", _tools.Select(x => $"{x.Value.Name}, {x.Value.Description}")); - var chain = - Set(() => _userInput, "input") - | Set(tools, "tools") - | Set(toolNames, "tool_names") - | Set(_actionPrompt, "action") - | Set(_gameSummary, "summary") - | LoadMemory(_conversationBufferMemory, "history") - | Template(_reActPrompt) - | LLM(_model).UseCache(_useCache) - | UpdateMemory(_conversationBufferMemory, "input", "text") - | ReActParser("text", ReActAnswer); - - _chain = chain; + if (_characters == "") + { + var chain = + Set(() => _userInput, "input") + | Set(tools, "tools") + | Set(toolNames, "tool_names") + | Set(_actionPrompt, "action") + | Set(_gameSummary, "summary") + | LoadMemory(_conversationBufferMemory, "history") + | Template(_reActPrompt) + | LLM(_model).UseCache(_useCache) + | UpdateMemory(_conversationBufferMemory, "input", "text") + | ReActParser("text", ReActAnswer); + + _chain = chain; + } + else + { + var chain = + Set(() => _userInput, "input") + | Set(tools, "tools") + | Set(toolNames, "tool_names") + | Set(_characters, "characters") + | Set(_environments, "environments") + | Set(_gameSummary, "summary") + | LoadMemory(_conversationBufferMemory, "history") + | Template(_reActPrompt) + | LLM(_model).UseCache(_useCache) + | UpdateMemory(_conversationBufferMemory, "input", "text") + | ReActParser("text", ReActAnswer); + + _chain = chain; + } } public ReActAgentChain UseCache(bool enabled = true) diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 7ddaa78..7870818 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -4,8 +4,6 @@ using LangChain.Providers.OpenAI; using LangChain.Providers.OpenAI.Predefined; using static LangChain.Chains.Chain; -using Message = ChatRPG.Data.Models.Message; -using MessageRole = ChatRPG.Data.Models.MessageRole; namespace ChatRPG.API; @@ -28,21 +26,17 @@ public async Task GetChatCompletionAsync(Campaign campaign, string actio { var llm = new Gpt4Model(_provider) { - Settings = new OpenAiChatSettings() { UseStreaming = false } + Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } }; - var agent = new ReActAgentChain(llm, _reActPrompt, actionPrompt, campaign.GameSummary); - var tools = CreateTools(campaign); + var agent = new ReActAgentChain(llm, _reActPrompt, actionPrompt:actionPrompt, campaign.GameSummary); + var tools = CreateTools(campaign, actionPrompt); foreach (var tool in tools) { agent.UseTool(tool); } var chain = Set(input, "input") | agent; - var result = await chain.RunAsync("text"); - - await UpdateCampaign(campaign, input, result!); - - return result!; + return (await chain.RunAsync("text"))!; } public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt, @@ -50,12 +44,12 @@ public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign ca { var agentLlm = new Gpt4Model(_provider) { - Settings = new OpenAiChatSettings() { UseStreaming = true, Temperature = 0.7, } + Settings = new OpenAiChatSettings() { UseStreaming = true, Temperature = 0.7 } }; var eventProcessor = new LlmEventProcessor(agentLlm); - var agent = new ReActAgentChain(agentLlm, _reActPrompt, actionPrompt, campaign.GameSummary); - var tools = CreateTools(campaign); + var agent = new ReActAgentChain(agentLlm, _reActPrompt, actionPrompt:actionPrompt, campaign.GameSummary); + var tools = CreateTools(campaign, actionPrompt); foreach (var tool in tools) { agent.UseTool(tool); @@ -70,54 +64,31 @@ public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign ca yield return content; } - var result = await response; - - await UpdateCampaign(campaign, input, result!); + await response; } - private async Task UpdateCampaign(Campaign campaign, string playerInput, string assistantOutput) - { - var newMessages = new List - { - new(playerInput, LangChain.Providers.MessageRole.Human), - new(assistantOutput, LangChain.Providers.MessageRole.Ai) - }; - - var summaryLlm = new Gpt4Model(_provider) - { - Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } - }; - - campaign.GameSummary = await summaryLlm.SummarizeAsync(newMessages, campaign.GameSummary ?? ""); - foreach (var message in newMessages) - { - // Only add the message, if the list is empty. - // This is because if the list is empty, the input is the initial prompt. Not player input. - if (campaign.Messages.Count == 0 && message.Role == LangChain.Providers.MessageRole.Human) - { - continue; - } - - campaign.Messages.Add(new Message(campaign, - (message.Role == LangChain.Providers.MessageRole.Human ? MessageRole.User : MessageRole.Assistant), - message.Content.Trim())); - } - } - - private List CreateTools(Campaign campaign) + private List CreateTools(Campaign campaign, string actionPrompt) { var tools = new List(); var utils = new ToolUtilities(_configuration); + /*var narrativeTool = new NarrativeTool(_configuration, campaign, actionPrompt, "narrativetool", + "This tool must be used when the player's input requires a narrative response. " + + "The tool is appropriate for any action that requires a narrative response. " + + "Example: A player's input could be to explore a new area, " + + "interact with a non-player character, or perform a specific action. " + + "Input to this tool must be the player's most recent action."); + tools.Add(narrativeTool);*/ + var woundCharacterTool = new WoundCharacterTool(_configuration, campaign, utils, "woundcharactertool", "This tool must be used when a character will be hurt or wounded resulting from unnoticed attacks" + "or performing dangerous activities that will lead to injury. The tool is only appropriate if the damage " + "cannot be mitigated, dodged, or avoided. Example: A character performs a sneak attack " + "without being spotted by the enemies they try to attack. A dangerous activity could be to threateningly " + "approach a King, which may result in injury when his guards step forward to stop the character. " + - "Input to this tool must be in the following RAW JSON format: {\"input\": \"The game summary appended with the " + - "player's input\", \"severity\": \"Describes how devastating the injury to the character will be based on " + - "the action. Can be one of the following values: {low, medium, high, extraordinary}}\". Do not use markdown, " + + "Input to this tool must be in the following RAW JSON format: {\"input\": \"The player's input\", " + + "\"severity\": \"Describes how devastating the injury to the character will be based on the action. " + + "Can be one of the following values: {low, medium, high, extraordinary}}\". Do not use markdown, " + "only raw JSON as input. Use this tool only once per character at most."); tools.Add(woundCharacterTool); @@ -128,9 +99,9 @@ private List CreateTools(Campaign campaign) "the character. Another example would be a scenario where a character consumes a beneficial item like " + "a potion, a magical item, or spends time in an area that could provide healing " + "benefits. Resting may provide modest healing effects depending on the duration of the rest. " + - "Input to this tool must be in the following RAW JSON format: {\"input\": \"The game " + - "summary appended with the player's input\", \"magnitude\": \"Describes how much health the character will " + - "regain based on the action. Can be one of the following values: {low, medium, high, extraordinary}}\". " + + "Input to this tool must be in the following RAW JSON format: {\"input\": \"The player's input\", " + + "\"magnitude\": \"Describes how much health the character will regain based on the action. " + + "Can be one of the following values: {low, medium, high, extraordinary}}\". " + "Do not use markdown, only raw JSON as input. Use this tool only once per character at most."); tools.Add(healCharacterTool); diff --git a/ChatRPG/API/Tools/HealCharacterTool.cs b/ChatRPG/API/Tools/HealCharacterTool.cs index 4b3c1de..9ca2986 100644 --- a/ChatRPG/API/Tools/HealCharacterTool.cs +++ b/ChatRPG/API/Tools/HealCharacterTool.cs @@ -28,11 +28,11 @@ public class HealCharacterTool( { try { - var effectInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var healInput = JsonSerializer.Deserialize(input, JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("HealCharacterInstruction")!; - var character = await utilities.FindCharacter(campaign, effectInput.Input!, instruction); + var character = await utilities.FindCharacter(campaign, healInput.Input!, instruction); if (character is null) { @@ -42,7 +42,7 @@ public class HealCharacterTool( // Determine damage Random rand = new Random(); - var (minHealing, maxHealing) = HealingRanges[effectInput.Magnitude!]; + var (minHealing, maxHealing) = HealingRanges[healInput.Magnitude!]; var healing = rand.Next(minHealing, maxHealing); character.AdjustHealth(healing); diff --git a/ChatRPG/API/Tools/NarrativeTool.cs b/ChatRPG/API/Tools/NarrativeTool.cs new file mode 100644 index 0000000..2ef2316 --- /dev/null +++ b/ChatRPG/API/Tools/NarrativeTool.cs @@ -0,0 +1,28 @@ +using ChatRPG.Data.Models; +using LangChain.Chains.StackableChains.Agents.Tools; +using LangChain.Providers; +using LangChain.Providers.OpenAI; +using LangChain.Providers.OpenAI.Predefined; + +namespace ChatRPG.API.Tools; + +public class NarrativeTool( + IConfiguration configuration, + Campaign campaign, + string actionPrompt, + string name, + string? description = null) : AgentTool(name, description) +{ + public override async Task ToolTask(string input, CancellationToken token = new CancellationToken()) + { + var provider = new OpenAiProvider(configuration.GetSection("ApiKeys").GetValue("OpenAI")!); + var llm = new Gpt4Model(provider) + { + Settings = new OpenAiChatSettings() { UseStreaming = false } + }; + + var query = configuration.GetSection("SystemPrompts")?.GetValue("Narrative")! + .Replace("{action}", actionPrompt).Replace("{input}", input).Replace("{summary}", campaign.GameSummary); + return await llm.GenerateAsync(query!, cancellationToken: token); + } +} diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 8a448f1..14e280f 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -28,9 +28,11 @@ public class ToolUtilities(IConfiguration configuration) query.Append(configuration.GetSection("SystemPrompts")?.GetValue("FindCharacter")! .Replace("{instruction}", instruction)); - query.Append($"\n\nThe story up until now and the players newest action: {input}"); + query.Append($"\n\nThe story up until now: {campaign.GameSummary}"); - query.Append("\n\nHere is the list of all characters present in the story:\n\n{\n"); + query.Append($"\n\nThe player's newest action: {input}"); + + query.Append("\n\nHere is the list of all characters present in the story:\n\n{\"characters\": [\n"); foreach (var character in campaign.Characters) { @@ -40,7 +42,7 @@ public class ToolUtilities(IConfiguration configuration) query.Length--; // Remove last comma - query.Append("\n}"); + query.Append("\n]}"); var response = await llm.GenerateAsync(query.ToString()); diff --git a/ChatRPG/API/Tools/UpdateCharacterInput.cs b/ChatRPG/API/Tools/UpdateCharacterInput.cs new file mode 100644 index 0000000..17eaf9a --- /dev/null +++ b/ChatRPG/API/Tools/UpdateCharacterInput.cs @@ -0,0 +1,9 @@ +namespace ChatRPG.API.Tools; + +public class UpdateCharacterInput +{ + public string? Name { get; set; } + public string? Description { get; set; } + public string? Type { get; set; } + public string? State { get; set; } +} diff --git a/ChatRPG/API/Tools/UpdateCharacterTool.cs b/ChatRPG/API/Tools/UpdateCharacterTool.cs new file mode 100644 index 0000000..999b969 --- /dev/null +++ b/ChatRPG/API/Tools/UpdateCharacterTool.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using ChatRPG.Data.Models; +using LangChain.Chains.StackableChains.Agents.Tools; +using Microsoft.IdentityModel.Tokens; + +namespace ChatRPG.API.Tools; + +public class UpdateCharacterTool( + Campaign campaign, + string name, + string? description = null) : AgentTool(name, description) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public override Task ToolTask(string input, CancellationToken token = new CancellationToken()) + { + try + { + var updateCharacterInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + throw new JsonException("Failed to deserialize"); + if (updateCharacterInput.Name.IsNullOrEmpty()) + { + return Task.FromResult("No character name was provided. Please provide a name for the character."); + } + + if (updateCharacterInput.Description.IsNullOrEmpty()) + { + return Task.FromResult( + "No character description was provided. Please provide a description for the character."); + } + + if (updateCharacterInput.Type.IsNullOrEmpty()) + { + return Task.FromResult("No character type was provided. Please provide a type for the character."); + } + + if (updateCharacterInput.State.IsNullOrEmpty()) + { + return Task.FromResult( + "No character state was provided. Please provide a health state for the character."); + } + + try + { + var character = campaign.Characters + .First(c => c.Name == updateCharacterInput.Name && + c.Type.ToString().Equals(updateCharacterInput.Type, + StringComparison.CurrentCultureIgnoreCase)); + + character.Description = updateCharacterInput.Description!; + character.Environment = campaign.Player.Environment; + return Task.FromResult( + $"{character.Name} has been updated with the following description: {updateCharacterInput.Description}"); + } + catch (InvalidOperationException) + { + // The character was not found in the campaign database + var newCharacter = new Character(campaign, campaign.Player.Environment, + Enum.Parse(updateCharacterInput.Type!), updateCharacterInput.Name!, + updateCharacterInput.Description!, false); + + newCharacter.AdjustHealth((int)-(newCharacter.MaxHealth - + newCharacter.MaxHealth * + ScaleHealthBasedOnState(updateCharacterInput.State!))); + campaign.Characters.Add(newCharacter); + return Task.FromResult( + $"A new character named {newCharacter.Name} has been created with the following description: " + + $"{newCharacter.Description}"); + } + } + catch (JsonException) + { + return Task.FromResult("Could not determine the character to update. Tool input format was invalid. " + + "Please provide a valid character name, description, type, and state in valid JSON without markdown."); + } + } + + private static double ScaleHealthBasedOnState(string state) + { + return state switch + { + "Dead" => 0, + "Unconscious" => 0.1, + "HeavilyWounded" => 0.35, + "LightlyWounded" => 0.75, + "Healthy" => 1, + _ => 1 + }; + } +} diff --git a/ChatRPG/Pages/CampaignPage.razor b/ChatRPG/Pages/CampaignPage.razor index acca1bb..6fb3647 100644 --- a/ChatRPG/Pages/CampaignPage.razor +++ b/ChatRPG/Pages/CampaignPage.razor @@ -75,7 +75,6 @@ -
diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 6a410e1..3c0dc4a 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -34,8 +34,7 @@ public partial class CampaignPage private static readonly Dictionary InputPlaceholder = new() { { UserPromptType.Do, "What do you do?" }, - { UserPromptType.Say, "What do you say?" }, - { UserPromptType.Attack, "How do you attack?" } + { UserPromptType.Say, "What do you say?" } }; private string SpinnerContainerStyle => _isWaitingForResponse @@ -70,7 +69,7 @@ protected override async Task OnInitializedAsync() { _npcList = _campaign!.Characters.Where(c => !c.IsPlayer).ToList(); _npcList.Reverse(); - _currentLocation = _campaign.Environments.LastOrDefault(); + _currentLocation = _campaign.Player.Environment; _mainCharacter = _campaign.Player; _conversation = _campaign.Messages.OrderBy(m => m.Timestamp) @@ -223,9 +222,6 @@ private void OnPromptTypeChange(UserPromptType type) case UserPromptType.Say: _userInputPlaceholder = InputPlaceholder[UserPromptType.Say]; break; - case UserPromptType.Attack: - _userInputPlaceholder = InputPlaceholder[UserPromptType.Attack]; - break; default: _userInputPlaceholder = InputPlaceholder[UserPromptType.Do]; break; diff --git a/ChatRPG/Pages/UserPromptType.cs b/ChatRPG/Pages/UserPromptType.cs index 460b127..8886fcd 100644 --- a/ChatRPG/Pages/UserPromptType.cs +++ b/ChatRPG/Pages/UserPromptType.cs @@ -3,7 +3,5 @@ public enum UserPromptType { Do, - Say, - Attack + Say } - diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index d8b9e95..0e2cd2a 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -275,16 +275,19 @@ private async Task GetResponseAndUpdateState(Campaign campaign, string actionPro OnChatCompletionChunkReceived(isStreamingDone: false, chunk); } - OnChatCompletionChunkReceived(isStreamingDone: true); + await _gameStateManager.StoreMessagesInCampaign(campaign, input, message.Content); + await _gameStateManager.UpdateCampaignFromNarrative(campaign, message.Content); } else { string response = await _llmClient.GetChatCompletionAsync(campaign, actionPrompt, input); OpenAiGptMessage message = new(MessageRole.Assistant, response); OnChatCompletionReceived(message); + await _gameStateManager.StoreMessagesInCampaign(campaign, input, response); + await _gameStateManager.UpdateCampaignFromNarrative(campaign, response); } - await _gameStateManager.SaveCurrentState(campaign); + OnChatCompletionChunkReceived(isStreamingDone: true); } } diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index 33bf0a0..74485f7 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -1,11 +1,124 @@ +using System.Text; +using ChatRPG.API; +using ChatRPG.API.Tools; using ChatRPG.Data.Models; +using LangChain.Chains.StackableChains.Agents.Tools; +using LangChain.Providers.OpenAI; +using LangChain.Providers.OpenAI.Predefined; +using static LangChain.Chains.Chain; namespace ChatRPG.Services; -public class GameStateManager(IPersistenceService persistenceService) +public class GameStateManager { + private readonly OpenAiProvider _provider; + private readonly IPersistenceService _persistenceService; + private readonly IConfiguration _configuration; + private readonly string _updateCampaignPrompt; + + public GameStateManager(IConfiguration configuration, IPersistenceService persistenceService) + { + _configuration = configuration; + ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("ApiKeys").GetValue("OpenAI")); + ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts").GetValue("UpdateCampaignFromNarrative")); + _provider = new OpenAiProvider(configuration.GetSection("ApiKeys").GetValue("OpenAI")!); + _updateCampaignPrompt = configuration.GetSection("SystemPrompts").GetValue("UpdateCampaignFromNarrative")!; + _persistenceService = persistenceService; + } + public async Task SaveCurrentState(Campaign campaign) { - await persistenceService.SaveAsync(campaign); + await _persistenceService.SaveAsync(campaign); + } + + public async Task UpdateCampaignFromNarrative(Campaign campaign, string narrative) + { + var llm = new Gpt4Model(_provider) + { + Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } + }; + + var characters = new StringBuilder(); + characters.Append("{\"characters\": [\n"); + + foreach (var character in campaign.Characters) + { + characters.Append( + $"{{\"name\": \"{character.Name}\", \"description\": \"{character.Description}\", \"type\": \"{character.Type}\"}},"); + } + + characters.Length--; // Remove last comma + characters.Append("\n]}"); + + var environments = new StringBuilder(); + environments.Append("{\"environments\": [\n"); + + foreach (var environment in campaign.Environments) + { + environments.Append($"{{\"name:\" \"{environment.Name}\", \"description\": \"{environment.Description}\"}},"); + } + + environments.Length--; // Remove last comma + + environments.Append("\n]}"); + + var agent = new ReActAgentChain(llm, _updateCampaignPrompt, characters.ToString(), environments.ToString(), + gameSummary: campaign.GameSummary); + + var tools = CreateTools(campaign); + foreach (var tool in tools) + { + agent.UseTool(tool); + } + + var chain = Set(narrative, "input") | agent; + await chain.RunAsync("text"); + } + + private List CreateTools(Campaign campaign) + { + var tools = new List(); + var utils = new ToolUtilities(_configuration); + + var updateCharacterTool = new UpdateCharacterTool(campaign, "updatecharactertool", + "This tool must be used to create a new character or update an existing character in the campaign. " + + "Example: The narrative text mentions a new character or contains changes to an existing character. " + + "Input to this tool must be in the following RAW JSON format: {\"name\": \"\", " + + "\"description\": \"\", \"type\": \"\", " + + "\"state\": \"\"}, where type is one of the following: {Humanoid, SmallCreature, " + + "LargeCreature, Monster}, and state is one of the following: {Dead, Unconscious, HeavilyWounded, LightlyWounded, Healthy}."); + tools.Add(updateCharacterTool); + + return tools; + } + + + public async Task StoreMessagesInCampaign(Campaign campaign, string playerInput, string assistantOutput) + { + var newMessages = new List + { + new(playerInput, LangChain.Providers.MessageRole.Human), + new(assistantOutput, LangChain.Providers.MessageRole.Ai) + }; + + var summaryLlm = new Gpt4Model(_provider) + { + Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } + }; + + campaign.GameSummary = await summaryLlm.SummarizeAsync(newMessages, campaign.GameSummary ?? ""); + foreach (var message in newMessages) + { + // Only add the message, if the list is empty. + // This is because if the list is empty, the input is the initial prompt. Not player input. + if (campaign.Messages.Count == 0 && message.Role == LangChain.Providers.MessageRole.Human) + { + continue; + } + + campaign.Messages.Add(new Message(campaign, + (message.Role == LangChain.Providers.MessageRole.Human ? MessageRole.User : MessageRole.Assistant), + message.Content.Trim())); + } } } diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index a782239..55cc1b2 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -17,8 +17,10 @@ "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { - "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} Previous tool steps: {history} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input}", + "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} Previous tool steps: {history} New input: {input}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", + "Narrative": "You are an expert game master in a single-player RPG. You are responsible for directing the narrative and controlling non-player characters. You must take the following into account when constructing the narrative: {action}. Game summary: {summary}. New input: {input}. Answer length: Concise and only a few, engaging sentences.", + "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. Environments in the game: {environments}. Game summary: {summary} Previous tool steps: {history} New narrative message: {input}", "CombatHitHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" }.", "CombatHitMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack will miss. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", "CombatMissHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done. Your job is to provide flavor text regarding this attack. The player's attack always misses. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", @@ -29,6 +31,6 @@ "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction}. Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object.", "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury", - "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, or through consuming a health potion, or by resting" + "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting" } } From 95bfc3854d94434975946beaf856bdec35238ecc Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Tue, 29 Oct 2024 15:09:50 +0100 Subject: [PATCH 19/51] Remove NarrativeTool and clean up --- ChatRPG/API/ReActLlmClient.cs | 18 +++++------------ ChatRPG/API/Tools/HealCharacterTool.cs | 7 ++++--- ChatRPG/API/Tools/NarrativeTool.cs | 28 -------------------------- ChatRPG/appsettings.json | 2 -- 4 files changed, 9 insertions(+), 46 deletions(-) delete mode 100644 ChatRPG/API/Tools/NarrativeTool.cs diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 7870818..125f720 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -28,8 +28,8 @@ public async Task GetChatCompletionAsync(Campaign campaign, string actio { Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } }; - var agent = new ReActAgentChain(llm, _reActPrompt, actionPrompt:actionPrompt, campaign.GameSummary); - var tools = CreateTools(campaign, actionPrompt); + var agent = new ReActAgentChain(llm, _reActPrompt, actionPrompt: actionPrompt, campaign.GameSummary); + var tools = CreateTools(campaign); foreach (var tool in tools) { agent.UseTool(tool); @@ -48,8 +48,8 @@ public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign ca }; var eventProcessor = new LlmEventProcessor(agentLlm); - var agent = new ReActAgentChain(agentLlm, _reActPrompt, actionPrompt:actionPrompt, campaign.GameSummary); - var tools = CreateTools(campaign, actionPrompt); + var agent = new ReActAgentChain(agentLlm, _reActPrompt, actionPrompt: actionPrompt, campaign.GameSummary); + var tools = CreateTools(campaign); foreach (var tool in tools) { agent.UseTool(tool); @@ -67,19 +67,11 @@ public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign ca await response; } - private List CreateTools(Campaign campaign, string actionPrompt) + private List CreateTools(Campaign campaign) { var tools = new List(); var utils = new ToolUtilities(_configuration); - /*var narrativeTool = new NarrativeTool(_configuration, campaign, actionPrompt, "narrativetool", - "This tool must be used when the player's input requires a narrative response. " + - "The tool is appropriate for any action that requires a narrative response. " + - "Example: A player's input could be to explore a new area, " + - "interact with a non-player character, or perform a specific action. " + - "Input to this tool must be the player's most recent action."); - tools.Add(narrativeTool);*/ - var woundCharacterTool = new WoundCharacterTool(_configuration, campaign, utils, "woundcharactertool", "This tool must be used when a character will be hurt or wounded resulting from unnoticed attacks" + "or performing dangerous activities that will lead to injury. The tool is only appropriate if the damage " + diff --git a/ChatRPG/API/Tools/HealCharacterTool.cs b/ChatRPG/API/Tools/HealCharacterTool.cs index 9ca2986..dabb893 100644 --- a/ChatRPG/API/Tools/HealCharacterTool.cs +++ b/ChatRPG/API/Tools/HealCharacterTool.cs @@ -29,7 +29,7 @@ public class HealCharacterTool( try { var healInput = JsonSerializer.Deserialize(input, JsonOptions) ?? - throw new JsonException("Failed to deserialize"); + throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("HealCharacterInstruction")!; var character = await utilities.FindCharacter(campaign, healInput.Input!, instruction); @@ -47,8 +47,9 @@ public class HealCharacterTool( character.AdjustHealth(healing); - return $"The character {character.Name} is healed for {healing} health points. They now have {character.CurrentHealth} health points out of a total of {character.MaxHealth}."; - + return + $"The character {character.Name} is healed for {healing} health points. " + + $"They now have {character.CurrentHealth} health points out of a total of {character.MaxHealth}."; } catch (Exception) { diff --git a/ChatRPG/API/Tools/NarrativeTool.cs b/ChatRPG/API/Tools/NarrativeTool.cs deleted file mode 100644 index 2ef2316..0000000 --- a/ChatRPG/API/Tools/NarrativeTool.cs +++ /dev/null @@ -1,28 +0,0 @@ -using ChatRPG.Data.Models; -using LangChain.Chains.StackableChains.Agents.Tools; -using LangChain.Providers; -using LangChain.Providers.OpenAI; -using LangChain.Providers.OpenAI.Predefined; - -namespace ChatRPG.API.Tools; - -public class NarrativeTool( - IConfiguration configuration, - Campaign campaign, - string actionPrompt, - string name, - string? description = null) : AgentTool(name, description) -{ - public override async Task ToolTask(string input, CancellationToken token = new CancellationToken()) - { - var provider = new OpenAiProvider(configuration.GetSection("ApiKeys").GetValue("OpenAI")!); - var llm = new Gpt4Model(provider) - { - Settings = new OpenAiChatSettings() { UseStreaming = false } - }; - - var query = configuration.GetSection("SystemPrompts")?.GetValue("Narrative")! - .Replace("{action}", actionPrompt).Replace("{input}", input).Replace("{summary}", campaign.GameSummary); - return await llm.GenerateAsync(query!, cancellationToken: token); - } -} diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 55cc1b2..8d63cd5 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -19,14 +19,12 @@ "SystemPrompts": { "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} Previous tool steps: {history} New input: {input}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", - "Narrative": "You are an expert game master in a single-player RPG. You are responsible for directing the narrative and controlling non-player characters. You must take the following into account when constructing the narrative: {action}. Game summary: {summary}. New input: {input}. Answer length: Concise and only a few, engaging sentences.", "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. Environments in the game: {environments}. Game summary: {summary} Previous tool steps: {history} New narrative message: {input}", "CombatHitHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" }.", "CombatHitMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack will miss. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", "CombatMissHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done. Your job is to provide flavor text regarding this attack. The player's attack always misses. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", "CombatMissMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done. Your job is to provide flavor text regarding this attack. The player's attack always misses. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack always misses. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", "CombatOpponentDescription": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. The player has just attacked someone. Your job is to determine who the player is attacking. Always respond in valid JSON, and in this exact structure: { \"opponent\": \"name of current opponent\", \"characters\": [] }, where \"characters\" includes whoever the user is attacking if they have not previously appeared in the narrative, describing them concisely here in this exact way: { \"name\": \"Name of the character\", \"description\": \"Short description\", \"type\": \"Humanoid, SmallCreature, LargeCreature or Monster\" }.", - "DoActionHurtOrHeal": "You are an expert game master in a single-player RPG. The player has just input an action that they want to perform. Your job is to determine whether the player's action will hurt them, heal them, or both. For example, the player could stab themselves, which would hurt them. The player could also drink a potion or take a short rest, which would heal them. Always respond in valid JSON, and in this exact structure: {\"hurt\": true/false, \"heal\": true/false}.", "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction}. Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object.", From cb8b90e3a0c9def2f6cade20d9bc0d53e9a5cf0e Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Tue, 29 Oct 2024 15:20:55 +0100 Subject: [PATCH 20/51] Add action prompt back into ReAct prompt and clean up ReActAgentChain --- ChatRPG/API/ReActAgentChain.cs | 16 +++++++--------- ChatRPG/appsettings.json | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index 2f13bf7..82415f7 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -14,19 +14,17 @@ public sealed class ReActAgentChain : BaseStackableChain { private const string ReActAnswer = "answer"; private readonly ConversationBufferMemory _conversationBufferMemory; - private readonly ChatMessageHistory _chatMessageHistory; - private readonly MessageFormatter _messageFormatter; private readonly int _maxActions; private readonly IChatModel _model; private readonly string _reActPrompt; - private readonly string _actionPrompt; + private readonly string _actionPrompt = string.Empty; private StackChain? _chain; private readonly Dictionary _tools = new(); private bool _useCache; private string _userInput = string.Empty; private readonly string _gameSummary; - private string _characters = string.Empty; - private string _environments = string.Empty; + private readonly string _characters = string.Empty; + private readonly string _environments = string.Empty; public string DefaultPrompt = @"Assistant is a large language model trained by OpenAI. @@ -91,22 +89,22 @@ public ReActAgentChain( InputKeys = [inputKey]; OutputKeys = [outputKey]; - _messageFormatter = new MessageFormatter + var messageFormatter = new MessageFormatter { AiPrefix = "", HumanPrefix = "", SystemPrefix = "" }; - _chatMessageHistory = new ChatMessageHistory() + var chatMessageHistory = new ChatMessageHistory() { // Do not save human messages IsMessageAccepted = x => (x.Role != MessageRole.Human) }; - _conversationBufferMemory = new ConversationBufferMemory(_chatMessageHistory) + _conversationBufferMemory = new ConversationBufferMemory(chatMessageHistory) { - Formatter = _messageFormatter + Formatter = messageFormatter }; } diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 8d63cd5..b5eacdb 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -17,7 +17,7 @@ "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { - "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} Previous tool steps: {history} New input: {input}", + "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} Previous tool steps: {history} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. Environments in the game: {environments}. Game summary: {summary} Previous tool steps: {history} New narrative message: {input}", "CombatHitHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" }.", From 9ea8ca076a3ffd35159c49beb7c92b6731102a4c Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Thu, 31 Oct 2024 11:37:23 +0100 Subject: [PATCH 21/51] Error handling on chain errors, Dashboard button, System error message --- ChatRPG/API/ReActAgentChain.cs | 37 ++++++------------- ...ReActChainNoFinalAnswerReachedException.cs | 8 ++++ ChatRPG/Pages/CampaignPage.razor | 2 +- ChatRPG/Pages/CampaignPage.razor.cs | 37 ++++++++++++++++--- ChatRPG/Pages/Navbar.razor | 30 ++++++++++++++- ChatRPG/Pages/OpenAiGptMessageComponent.razor | 16 +++++--- ChatRPG/Services/GameInputHandler.cs | 15 +++++--- ChatRPG/Services/GameStateManager.cs | 16 ++++++-- ChatRPG/wwwroot/css/site.css | 8 ++++ 9 files changed, 121 insertions(+), 48 deletions(-) create mode 100644 ChatRPG/API/ReActChainNoFinalAnswerReachedException.cs diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index 82415f7..c758d51 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -141,30 +141,16 @@ private void InitializeChain() var tools = string.Join("\n", _tools.Select(x => $"{x.Value.Name}, {x.Value.Description}")); - if (_characters == "") - { - var chain = - Set(() => _userInput, "input") - | Set(tools, "tools") - | Set(toolNames, "tool_names") - | Set(_actionPrompt, "action") - | Set(_gameSummary, "summary") - | LoadMemory(_conversationBufferMemory, "history") - | Template(_reActPrompt) - | LLM(_model).UseCache(_useCache) - | UpdateMemory(_conversationBufferMemory, "input", "text") - | ReActParser("text", ReActAnswer); + var chain = + Set(() => _userInput, "input") + | Set(tools, "tools") + | Set(toolNames, "tool_names"); - _chain = chain; - } - else - { - var chain = - Set(() => _userInput, "input") - | Set(tools, "tools") - | Set(toolNames, "tool_names") - | Set(_characters, "characters") - | Set(_environments, "environments") + chain = _characters == "" + ? chain | Set(_actionPrompt, "action") + : chain | Set(_characters, "characters") | Set(_environments, "environments"); + + chain = chain | Set(_gameSummary, "summary") | LoadMemory(_conversationBufferMemory, "history") | Template(_reActPrompt) @@ -172,8 +158,7 @@ private void InitializeChain() | UpdateMemory(_conversationBufferMemory, "input", "text") | ReActParser("text", ReActAnswer); - _chain = chain; - } + _chain = chain; } public ReActAgentChain UseCache(bool enabled = true) @@ -227,6 +212,6 @@ await _conversationBufferMemory.ChatHistory.AddMessage(new Message("Thought:", M } } - return values; + throw new ReActChainNoFinalAnswerReachedException("The ReAct Chain could not reach a final answer", values); } } diff --git a/ChatRPG/API/ReActChainNoFinalAnswerReachedException.cs b/ChatRPG/API/ReActChainNoFinalAnswerReachedException.cs new file mode 100644 index 0000000..025fe6d --- /dev/null +++ b/ChatRPG/API/ReActChainNoFinalAnswerReachedException.cs @@ -0,0 +1,8 @@ +using LangChain.Abstractions.Schema; + +namespace ChatRPG.API; + +public class ReActChainNoFinalAnswerReachedException(string message, IChainValues chainValues) : Exception(message) +{ + public IChainValues ChainValues { get; set; } = chainValues; +} diff --git a/ChatRPG/Pages/CampaignPage.razor b/ChatRPG/Pages/CampaignPage.razor index 6fb3647..a9ff88e 100644 --- a/ChatRPG/Pages/CampaignPage.razor +++ b/ChatRPG/Pages/CampaignPage.razor @@ -54,7 +54,7 @@
- @foreach (OpenAiGptMessage message in _conversation.Where(m => !m.Role.Equals(MessageRole.System))) + @foreach (OpenAiGptMessage message in _conversation) { } diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 3c0dc4a..7e2a482 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -114,8 +114,22 @@ private void InitializeCampaign() _isWaitingForResponse = true; OpenAiGptMessage message = new(MessageRole.System, content); _conversation.Add(message); - GameInputHandler?.HandleInitialPrompt(_campaign, content); - UpdateStatsUi(); + + try + { + GameInputHandler?.HandleInitialPrompt(_campaign, content); + } + catch (Exception) + { + _conversation.Add(new OpenAiGptMessage(MessageRole.System, + "An error occurred when generating the response \uD83D\uDCA9. " + + "Please try again by reloading the campaign.")); + _isWaitingForResponse = false; + } + finally + { + UpdateStatsUi(); + } } /// @@ -146,9 +160,22 @@ private async Task SendPrompt() _latestPlayerMessage = userInput; _userInput = string.Empty; await ScrollToElement(BottomId); - await GameInputHandler!.HandleUserPrompt(_campaign, _activeUserPromptType, userInput.Content); - _conversation.RemoveAll(m => m.Role.Equals(MessageRole.System)); - UpdateStatsUi(); + try + { + await GameInputHandler!.HandleUserPrompt(_campaign, _activeUserPromptType, userInput.Content); + _conversation.RemoveAll(m => m.Role.Equals(MessageRole.System)); + } + catch (Exception) + { + _conversation.Add(new OpenAiGptMessage(MessageRole.System, + "An error occurred when generating the response \uD83D\uDCA9. Please try again.")); + _campaign = await PersistenceService!.LoadFromCampaignIdAsync(_campaign.Id); // Rollback campaign + _isWaitingForResponse = false; + } + finally + { + UpdateStatsUi(); + } } /// diff --git a/ChatRPG/Pages/Navbar.razor b/ChatRPG/Pages/Navbar.razor index da1d262..da7ba59 100644 --- a/ChatRPG/Pages/Navbar.razor +++ b/ChatRPG/Pages/Navbar.razor @@ -16,6 +16,10 @@
+ @@ -29,8 +33,30 @@ @code { + private bool _isHidden; + + [Parameter, EditorRequired] public string Username { get; set; } = null!; + + protected override void OnInitialized() + { + NavMan.LocationChanged += HandleLocationChanged; + UpdateVisibility(); + } + + private void HandleLocationChanged(object? sender, LocationChangedEventArgs e) + { + UpdateVisibility(); + InvokeAsync(StateHasChanged); + } + + private void UpdateVisibility() + { + _isHidden = NavMan.Uri.TrimEnd('/') == NavMan.BaseUri.TrimEnd('/'); + } - [Parameter, EditorRequired] - public string Username { get; set; } = null!; + public void Dispose() + { + NavMan.LocationChanged -= HandleLocationChanged; + } } diff --git a/ChatRPG/Pages/OpenAiGptMessageComponent.razor b/ChatRPG/Pages/OpenAiGptMessageComponent.razor index 818900e..9ce316f 100644 --- a/ChatRPG/Pages/OpenAiGptMessageComponent.razor +++ b/ChatRPG/Pages/OpenAiGptMessageComponent.razor @@ -3,13 +3,19 @@ @inherits ComponentBase
-

@(MessagePrefix): @(Message.Content)

+

+ @(MessagePrefix): @(Message.Content) +

@code { - [Parameter] - public required OpenAiGptMessage Message { get; set; } + [Parameter] public required OpenAiGptMessage Message { get; set; } + + private string MessagePrefix => Message.Role switch + { + Assistant => "GM", + System => "System", + _ => "Player" + }; - private string MessagePrefix => Message.Role.Equals(Assistant) - ? "GM" : "Player"; } diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index 0e2cd2a..3cbe4f9 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -275,19 +275,24 @@ private async Task GetResponseAndUpdateState(Campaign campaign, string actionPro OnChatCompletionChunkReceived(isStreamingDone: false, chunk); } - await _gameStateManager.StoreMessagesInCampaign(campaign, input, message.Content); - await _gameStateManager.UpdateCampaignFromNarrative(campaign, message.Content); + await SaveInteraction(campaign, input, message.Content); + OnChatCompletionChunkReceived(isStreamingDone: true); } else { string response = await _llmClient.GetChatCompletionAsync(campaign, actionPrompt, input); OpenAiGptMessage message = new(MessageRole.Assistant, response); OnChatCompletionReceived(message); - await _gameStateManager.StoreMessagesInCampaign(campaign, input, response); - await _gameStateManager.UpdateCampaignFromNarrative(campaign, response); + + await SaveInteraction(campaign, input, response); } + } + + private async Task SaveInteraction(Campaign campaign, string input, string response) + { + await _gameStateManager.StoreMessagesInCampaign(campaign, input, response); + await _gameStateManager.UpdateCampaignFromNarrative(campaign, response); await _gameStateManager.SaveCurrentState(campaign); - OnChatCompletionChunkReceived(isStreamingDone: true); } } diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index 74485f7..4cf6b88 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -6,6 +6,8 @@ using LangChain.Providers.OpenAI; using LangChain.Providers.OpenAI.Predefined; using static LangChain.Chains.Chain; +using Message = ChatRPG.Data.Models.Message; +using MessageRole = ChatRPG.Data.Models.MessageRole; namespace ChatRPG.Services; @@ -20,9 +22,11 @@ public GameStateManager(IConfiguration configuration, IPersistenceService persis { _configuration = configuration; ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("ApiKeys").GetValue("OpenAI")); - ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts").GetValue("UpdateCampaignFromNarrative")); + ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts") + .GetValue("UpdateCampaignFromNarrative")); _provider = new OpenAiProvider(configuration.GetSection("ApiKeys").GetValue("OpenAI")!); - _updateCampaignPrompt = configuration.GetSection("SystemPrompts").GetValue("UpdateCampaignFromNarrative")!; + _updateCampaignPrompt = + configuration.GetSection("SystemPrompts").GetValue("UpdateCampaignFromNarrative")!; _persistenceService = persistenceService; } @@ -55,7 +59,8 @@ public async Task UpdateCampaignFromNarrative(Campaign campaign, string narrativ foreach (var environment in campaign.Environments) { - environments.Append($"{{\"name:\" \"{environment.Name}\", \"description\": \"{environment.Description}\"}},"); + environments.Append( + $"{{\"name:\" \"{environment.Name}\", \"description\": \"{environment.Description}\"}},"); } environments.Length--; // Remove last comma @@ -86,7 +91,10 @@ private List CreateTools(Campaign campaign) "Input to this tool must be in the following RAW JSON format: {\"name\": \"\", " + "\"description\": \"\", \"type\": \"\", " + "\"state\": \"\"}, where type is one of the following: {Humanoid, SmallCreature, " + - "LargeCreature, Monster}, and state is one of the following: {Dead, Unconscious, HeavilyWounded, LightlyWounded, Healthy}."); + "LargeCreature, Monster}, and state is one of the following: {Dead, Unconscious, HeavilyWounded, " + + "LightlyWounded, Healthy}. The description of a character could describe their physical characteristics, " + + "personality, what they are known for, or other cool descriptive features. " + + "The tool should only be used once per character per narrative."); tools.Add(updateCharacterTool); return tools; diff --git a/ChatRPG/wwwroot/css/site.css b/ChatRPG/wwwroot/css/site.css index 4918332..04c27af 100644 --- a/ChatRPG/wwwroot/css/site.css +++ b/ChatRPG/wwwroot/css/site.css @@ -118,6 +118,11 @@ a, .btn-link { background-color: rgb(222, 222, 222); } +.message-container-system { + background-color: rgb(222, 222, 222); + border: 3px dashed darkred; +} + .message { color: rgb(0, 0, 0); margin: 10px 0; @@ -129,6 +134,9 @@ a, .btn-link { .message-gm { } +.message-system { +} + .custom-text-field { border: none; border-radius: 0; From 643404bf6afa119044546a0409f09b116fbf04e9 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Thu, 31 Oct 2024 13:56:07 +0100 Subject: [PATCH 22/51] Remove CharacterAbilities and list of Characters from Environments --- ChatRPG/API/Tools/UpdateEnvironmentInput.cs | 8 + ChatRPG/API/Tools/UpdateEnvironmentTool.cs | 32 + ChatRPG/Data/ApplicationDbContext.cs | 22 +- ..._RemoveCharactersOnEnvironment.Designer.cs | 575 ++++++++++++++++++ ...031122939_RemoveCharactersOnEnvironment.cs | 22 + ...25205_RemoveCharacterAbilities.Designer.cs | 508 ++++++++++++++++ ...20241031125205_RemoveCharacterAbilities.cs | 69 +++ .../ApplicationDbContextModelSnapshot.cs | 74 +-- ChatRPG/Data/Models/Ability.cs | 21 - ChatRPG/Data/Models/AbilityType.cs | 7 - ChatRPG/Data/Models/Character.cs | 19 - ChatRPG/Data/Models/CharacterAbility.cs | 19 - ChatRPG/Data/Models/Environment.cs | 1 - ChatRPG/Services/EfPersistenceService.cs | 3 - ChatRPG/Services/GameStateManager.cs | 9 + 15 files changed, 1226 insertions(+), 163 deletions(-) create mode 100644 ChatRPG/API/Tools/UpdateEnvironmentInput.cs create mode 100644 ChatRPG/API/Tools/UpdateEnvironmentTool.cs create mode 100644 ChatRPG/Data/Migrations/20241031122939_RemoveCharactersOnEnvironment.Designer.cs create mode 100644 ChatRPG/Data/Migrations/20241031122939_RemoveCharactersOnEnvironment.cs create mode 100644 ChatRPG/Data/Migrations/20241031125205_RemoveCharacterAbilities.Designer.cs create mode 100644 ChatRPG/Data/Migrations/20241031125205_RemoveCharacterAbilities.cs delete mode 100644 ChatRPG/Data/Models/Ability.cs delete mode 100644 ChatRPG/Data/Models/AbilityType.cs delete mode 100644 ChatRPG/Data/Models/CharacterAbility.cs diff --git a/ChatRPG/API/Tools/UpdateEnvironmentInput.cs b/ChatRPG/API/Tools/UpdateEnvironmentInput.cs new file mode 100644 index 0000000..531c375 --- /dev/null +++ b/ChatRPG/API/Tools/UpdateEnvironmentInput.cs @@ -0,0 +1,8 @@ +namespace ChatRPG.API.Tools; + +public class UpdateEnvironmentInput +{ + public string? Name { get; set; } + public string? Description { get; set; } + public List? Characters { get; set; } +} diff --git a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs new file mode 100644 index 0000000..5d83a14 --- /dev/null +++ b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using ChatRPG.Data.Models; +using LangChain.Chains.StackableChains.Agents.Tools; + +namespace ChatRPG.API.Tools; + +public class UpdateEnvironmentTool( + Campaign campaign, + string name, + string? description = null) : AgentTool(name, description) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public override Task ToolTask(string input, CancellationToken token = new CancellationToken()) + { + try + { + + throw new NotImplementedException(); + + + } + catch (JsonException) + { + return Task.FromResult("Could not determine the environment to update. Tool input format was invalid. " + + "Please provide a valid environment name, description, and list of characters in valid JSON without markdown."); + } + } +} diff --git a/ChatRPG/Data/ApplicationDbContext.cs b/ChatRPG/Data/ApplicationDbContext.cs index 81f92cc..c704bb9 100644 --- a/ChatRPG/Data/ApplicationDbContext.cs +++ b/ChatRPG/Data/ApplicationDbContext.cs @@ -5,29 +5,11 @@ namespace ChatRPG.Data; -public sealed class ApplicationDbContext : IdentityDbContext +public sealed class ApplicationDbContext(DbContextOptions options) + : IdentityDbContext(options) { public DbSet StartScenarios { get; private set; } = null!; public DbSet Campaigns { get; private set; } = null!; public DbSet Characters { get; private set; } = null!; public DbSet Environments { get; private set; } = null!; - public DbSet Abilities { get; private set; } = null!; - public DbSet CharacterAbilities { get; private set; } = null!; - - public ApplicationDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder - .Entity(builder => - { - builder.HasKey("CharacterId", "AbilityId"); - builder.HasOne(c => c.Character).WithMany(c => c.CharacterAbilities!); - }); - } } diff --git a/ChatRPG/Data/Migrations/20241031122939_RemoveCharactersOnEnvironment.Designer.cs b/ChatRPG/Data/Migrations/20241031122939_RemoveCharactersOnEnvironment.Designer.cs new file mode 100644 index 0000000..46f7902 --- /dev/null +++ b/ChatRPG/Data/Migrations/20241031122939_RemoveCharactersOnEnvironment.Designer.cs @@ -0,0 +1,575 @@ +// +using System; +using ChatRPG.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20241031122939_RemoveCharactersOnEnvironment")] + partial class RemoveCharactersOnEnvironment + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Abilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameSummary") + .HasColumnType("text"); + + b.Property("StartScenario") + .HasColumnType("text"); + + b.Property("StartScenarioId") + .HasColumnType("integer"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StartScenarioId"); + + b.HasIndex("UserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("CurrentHealth") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("IsPlayer") + .HasColumnType("boolean"); + + b.Property("MaxHealth") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => + { + b.Property("CharacterId") + .HasColumnType("integer"); + + b.Property("AbilityId") + .HasColumnType("integer"); + + b.HasKey("CharacterId", "AbilityId"); + + b.HasIndex("AbilityId"); + + b.ToTable("CharacterAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Message"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("StartScenarios"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.HasOne("ChatRPG.Data.Models.StartScenario", null) + .WithMany("Campaigns") + .HasForeignKey("StartScenarioId"); + + b.HasOne("ChatRPG.Data.Models.User", "User") + .WithMany("Campaigns") + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Characters") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => + { + b.HasOne("ChatRPG.Data.Models.Ability", "Ability") + .WithMany("CharactersAbilities") + .HasForeignKey("AbilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Character", "Character") + .WithMany("CharacterAbilities") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ability"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Environments") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Messages") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => + { + b.Navigation("CharactersAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Navigation("Characters"); + + b.Navigation("Environments"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Navigation("CharacterAbilities"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Navigation("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Navigation("Campaigns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ChatRPG/Data/Migrations/20241031122939_RemoveCharactersOnEnvironment.cs b/ChatRPG/Data/Migrations/20241031122939_RemoveCharactersOnEnvironment.cs new file mode 100644 index 0000000..e07ee85 --- /dev/null +++ b/ChatRPG/Data/Migrations/20241031122939_RemoveCharactersOnEnvironment.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + /// + public partial class RemoveCharactersOnEnvironment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/ChatRPG/Data/Migrations/20241031125205_RemoveCharacterAbilities.Designer.cs b/ChatRPG/Data/Migrations/20241031125205_RemoveCharacterAbilities.Designer.cs new file mode 100644 index 0000000..456fa25 --- /dev/null +++ b/ChatRPG/Data/Migrations/20241031125205_RemoveCharacterAbilities.Designer.cs @@ -0,0 +1,508 @@ +// +using System; +using ChatRPG.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20241031125205_RemoveCharacterAbilities")] + partial class RemoveCharacterAbilities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameSummary") + .HasColumnType("text"); + + b.Property("StartScenario") + .HasColumnType("text"); + + b.Property("StartScenarioId") + .HasColumnType("integer"); + + b.Property("StartedOn") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StartScenarioId"); + + b.HasIndex("UserId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("CurrentHealth") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("IsPlayer") + .HasColumnType("boolean"); + + b.Property("MaxHealth") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("Message"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("StartScenarios"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.HasOne("ChatRPG.Data.Models.StartScenario", null) + .WithMany("Campaigns") + .HasForeignKey("StartScenarioId"); + + b.HasOne("ChatRPG.Data.Models.User", "User") + .WithMany("Campaigns") + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Character", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Characters") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.Environment", "Environment") + .WithMany() + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + + b.Navigation("Environment"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Environments") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Message", b => + { + b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") + .WithMany("Messages") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ChatRPG.Data.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => + { + b.Navigation("Characters"); + + b.Navigation("Environments"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => + { + b.Navigation("Campaigns"); + }); + + modelBuilder.Entity("ChatRPG.Data.Models.User", b => + { + b.Navigation("Campaigns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ChatRPG/Data/Migrations/20241031125205_RemoveCharacterAbilities.cs b/ChatRPG/Data/Migrations/20241031125205_RemoveCharacterAbilities.cs new file mode 100644 index 0000000..1c907f4 --- /dev/null +++ b/ChatRPG/Data/Migrations/20241031125205_RemoveCharacterAbilities.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ChatRPG.Data.Migrations +{ + /// + public partial class RemoveCharacterAbilities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CharacterAbilities"); + + migrationBuilder.DropTable( + name: "Abilities"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Abilities", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "text", nullable: false), + Type = table.Column(type: "integer", nullable: false), + Value = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Abilities", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CharacterAbilities", + columns: table => new + { + CharacterId = table.Column(type: "integer", nullable: false), + AbilityId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CharacterAbilities", x => new { x.CharacterId, x.AbilityId }); + table.ForeignKey( + name: "FK_CharacterAbilities_Abilities_AbilityId", + column: x => x.AbilityId, + principalTable: "Abilities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CharacterAbilities_Characters_CharacterId", + column: x => x.CharacterId, + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CharacterAbilities_AbilityId", + table: "CharacterAbilities", + column: "AbilityId"); + } + } +} diff --git a/ChatRPG/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/ChatRPG/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 92acc3b..d098096 100644 --- a/ChatRPG/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/ChatRPG/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -22,29 +22,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("Type") - .HasColumnType("integer"); - - b.Property("Value") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.ToTable("Abilities"); - }); - modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => { b.Property("Id") @@ -124,21 +101,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Characters"); }); - modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => - { - b.Property("CharacterId") - .HasColumnType("integer"); - - b.Property("AbilityId") - .HasColumnType("integer"); - - b.HasKey("CharacterId", "AbilityId"); - - b.HasIndex("AbilityId"); - - b.ToTable("CharacterAbilities"); - }); - modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => { b.Property("Id") @@ -436,7 +398,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); b.HasOne("ChatRPG.Data.Models.Environment", "Environment") - .WithMany("Characters") + .WithMany() .HasForeignKey("EnvironmentId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -446,25 +408,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Environment"); }); - modelBuilder.Entity("ChatRPG.Data.Models.CharacterAbility", b => - { - b.HasOne("ChatRPG.Data.Models.Ability", "Ability") - .WithMany("CharactersAbilities") - .HasForeignKey("AbilityId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ChatRPG.Data.Models.Character", "Character") - .WithMany("CharacterAbilities") - .HasForeignKey("CharacterId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Ability"); - - b.Navigation("Character"); - }); - modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => { b.HasOne("ChatRPG.Data.Models.Campaign", "Campaign") @@ -538,11 +481,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("ChatRPG.Data.Models.Ability", b => - { - b.Navigation("CharactersAbilities"); - }); - modelBuilder.Entity("ChatRPG.Data.Models.Campaign", b => { b.Navigation("Characters"); @@ -552,16 +490,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Messages"); }); - modelBuilder.Entity("ChatRPG.Data.Models.Character", b => - { - b.Navigation("CharacterAbilities"); - }); - - modelBuilder.Entity("ChatRPG.Data.Models.Environment", b => - { - b.Navigation("Characters"); - }); - modelBuilder.Entity("ChatRPG.Data.Models.StartScenario", b => { b.Navigation("Campaigns"); diff --git a/ChatRPG/Data/Models/Ability.cs b/ChatRPG/Data/Models/Ability.cs deleted file mode 100644 index 2183946..0000000 --- a/ChatRPG/Data/Models/Ability.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace ChatRPG.Data.Models; - -public class Ability -{ - private Ability() - { - } - - public Ability(string name, AbilityType type, int value) - { - Name = name; - Type = type; - Value = value; - } - - public int Id { get; private set; } - public string Name { get; private set; } = null!; - public AbilityType Type { get; private set; } = AbilityType.Damage; - public int Value { get; private set; } - public ICollection CharactersAbilities { get; } = new List(); -} diff --git a/ChatRPG/Data/Models/AbilityType.cs b/ChatRPG/Data/Models/AbilityType.cs deleted file mode 100644 index d4dc4cd..0000000 --- a/ChatRPG/Data/Models/AbilityType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ChatRPG.Data.Models; - -public enum AbilityType -{ - Heal, - Damage -} diff --git a/ChatRPG/Data/Models/Character.cs b/ChatRPG/Data/Models/Character.cs index d3add16..e44f4a4 100644 --- a/ChatRPG/Data/Models/Character.cs +++ b/ChatRPG/Data/Models/Character.cs @@ -36,7 +36,6 @@ public Character(Campaign campaign, Environment environment, CharacterType type, public string Description { get; set; } = null!; public int MaxHealth { get; private set; } public int CurrentHealth { get; private set; } - public ICollection CharacterAbilities { get; } = new List(); /// /// Adjust the current health of this character. @@ -47,22 +46,4 @@ public bool AdjustHealth(int value) CurrentHealth = Math.Min(MaxHealth, Math.Max(0, CurrentHealth + value)); return CurrentHealth <= 0; } - - /// - /// Creates a for this character and the given , and adds it to its list of if it does not already exist. - /// - /// The ability to add. - /// The created entity. - public CharacterAbility? AddAbility(Ability ability) - { - CharacterAbility? charAbility = CharacterAbilities.FirstOrDefault(a => a!.Ability == ability, null); - if (charAbility is not null) - { - return charAbility; - } - charAbility = new CharacterAbility(this, ability); - CharacterAbilities.Add(charAbility); - - return charAbility; - } } diff --git a/ChatRPG/Data/Models/CharacterAbility.cs b/ChatRPG/Data/Models/CharacterAbility.cs deleted file mode 100644 index 5e179a1..0000000 --- a/ChatRPG/Data/Models/CharacterAbility.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace ChatRPG.Data.Models; - -public class CharacterAbility -{ - private CharacterAbility() - { - } - - public CharacterAbility(Character character, Ability ability) - { - Character = character; - Ability = ability; - } - - private int CharacterId { get; set; } - private int AbilityId { get; set; } - public Character Character { get; private set; } = null!; - public Ability Ability { get; private set; } = null!; -} diff --git a/ChatRPG/Data/Models/Environment.cs b/ChatRPG/Data/Models/Environment.cs index 0849c78..ef2f02e 100644 --- a/ChatRPG/Data/Models/Environment.cs +++ b/ChatRPG/Data/Models/Environment.cs @@ -17,5 +17,4 @@ public Environment(Campaign campaign, string name, string description) public Campaign Campaign { get; private set; } = null!; public string Name { get; private set; } = null!; public string Description { get; set; } = null!; - public ICollection Characters { get; } = new List(); } diff --git a/ChatRPG/Services/EfPersistenceService.cs b/ChatRPG/Services/EfPersistenceService.cs index 43f656d..6bc5cda 100644 --- a/ChatRPG/Services/EfPersistenceService.cs +++ b/ChatRPG/Services/EfPersistenceService.cs @@ -68,9 +68,6 @@ public async Task LoadFromCampaignIdAsync(int campaignId) .Include(campaign => campaign.Messages) .Include(campaign => campaign.Environments) .Include(campaign => campaign.Characters) - .ThenInclude(character => character.CharacterAbilities) - .ThenInclude(characterAbility => characterAbility!.Ability) - .AsSplitQuery() .FirstAsync(); } diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index 4cf6b88..1e62a96 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -96,6 +96,15 @@ private List CreateTools(Campaign campaign) "personality, what they are known for, or other cool descriptive features. " + "The tool should only be used once per character per narrative."); tools.Add(updateCharacterTool); + + var updateEnvironmentTool = new UpdateEnvironmentTool(campaign, "updateenvironmenttool", + "This tool must be used to create a new environment or update an existing environment in the campaign. " + + "Example: The narrative text mentions a new environment or contains changes to an existing environment. " + + "Input to this tool must be in the following RAW JSON format: {\"name\": \"\", " + + "\"description\": \"\"}, where the description of an environment " + + "could describe its physical characteristics, the creatures that inhabit it, the weather, or other cool " + + "descriptive features. The tool should only be used once per environment per narrative."); + tools.Add(updateEnvironmentTool); return tools; } From fd52df05cf675a50f89de3dc7917454e7f67f459 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Thu, 31 Oct 2024 15:07:48 +0100 Subject: [PATCH 23/51] UpdateEnvironmentTool has been created and seems to work --- ChatRPG/API/ReActAgentChain.cs | 5 +- ChatRPG/API/Tools/UpdateEnvironmentInput.cs | 2 +- ChatRPG/API/Tools/UpdateEnvironmentTool.cs | 51 ++++++++++++++++++++- ChatRPG/Data/Models/Campaign.cs | 24 ---------- ChatRPG/Services/GameStateManager.cs | 27 ++++++----- ChatRPG/appsettings.json | 2 +- 6 files changed, 71 insertions(+), 40 deletions(-) diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index c758d51..22ea617 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -23,6 +23,7 @@ public sealed class ReActAgentChain : BaseStackableChain private bool _useCache; private string _userInput = string.Empty; private readonly string _gameSummary; + private readonly string _playerCharacter = string.Empty; private readonly string _characters = string.Empty; private readonly string _environments = string.Empty; @@ -124,6 +125,7 @@ public ReActAgentChain( IChatModel model, string reActPrompt, string characters, + string playerCharacter, string environments, string? gameSummary = null, string inputKey = "input", @@ -131,6 +133,7 @@ public ReActAgentChain( int maxActions = 10) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) { _characters = characters; + _playerCharacter = playerCharacter; _environments = environments; } @@ -148,7 +151,7 @@ private void InitializeChain() chain = _characters == "" ? chain | Set(_actionPrompt, "action") - : chain | Set(_characters, "characters") | Set(_environments, "environments"); + : chain | Set(_characters, "characters") | Set(_environments, "environments") | Set(_playerCharacter, "player_character"); chain = chain | Set(_gameSummary, "summary") diff --git a/ChatRPG/API/Tools/UpdateEnvironmentInput.cs b/ChatRPG/API/Tools/UpdateEnvironmentInput.cs index 531c375..1231570 100644 --- a/ChatRPG/API/Tools/UpdateEnvironmentInput.cs +++ b/ChatRPG/API/Tools/UpdateEnvironmentInput.cs @@ -4,5 +4,5 @@ public class UpdateEnvironmentInput { public string? Name { get; set; } public string? Description { get; set; } - public List? Characters { get; set; } + public bool? IsPlayerHere { get; set; } } diff --git a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs index 5d83a14..b7e8b8d 100644 --- a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs +++ b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs @@ -1,6 +1,8 @@ using System.Text.Json; using ChatRPG.Data.Models; using LangChain.Chains.StackableChains.Agents.Tools; +using Microsoft.IdentityModel.Tokens; +using Environment = ChatRPG.Data.Models.Environment; namespace ChatRPG.API.Tools; @@ -18,15 +20,60 @@ public class UpdateEnvironmentTool( { try { + var updateEnvironmentInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + throw new JsonException("Failed to deserialize"); - throw new NotImplementedException(); + if (updateEnvironmentInput.Name.IsNullOrEmpty()) + { + return Task.FromResult("No environment name was provided. Please provide a name for the environment."); + } + if (updateEnvironmentInput.Description.IsNullOrEmpty()) + { + return Task.FromResult( + "No environment description was provided. Please provide a description for the environment."); + } + if (updateEnvironmentInput.IsPlayerHere is null) + { + return Task.FromResult( + "No IsPlayerHere boolean was provided. Please provide this boolean for the environment."); + } + + try + { + var environment = campaign.Environments.First(e => e.Name == updateEnvironmentInput.Name); + + environment.Description = updateEnvironmentInput.Description!; + if (updateEnvironmentInput.IsPlayerHere is true) + { + campaign.Player.Environment = environment; + } + + return Task.FromResult( + $"{environment.Name} has been updated with the following description {updateEnvironmentInput.Description}"); + } + catch (InvalidOperationException) + { + // The environment was not found in the campaign database + var newEnvironment = new Environment(campaign, updateEnvironmentInput.Name!, + updateEnvironmentInput.Description!); + + if (updateEnvironmentInput.IsPlayerHere is true) + { + campaign.Player.Environment = newEnvironment; + } + + campaign.Environments.Add(newEnvironment); + return Task.FromResult($"A new environment {newEnvironment.Name} has been created with the following " + + $"description: {newEnvironment.Description}"); + } } catch (JsonException) { return Task.FromResult("Could not determine the environment to update. Tool input format was invalid. " + - "Please provide a valid environment name, description, and list of characters in valid JSON without markdown."); + "Please provide a valid environment name, description, and determine if the " + + "player character is present in the environment in valid JSON without markdown."); } } } diff --git a/ChatRPG/Data/Models/Campaign.cs b/ChatRPG/Data/Models/Campaign.cs index ba2d0a6..aa43a2e 100644 --- a/ChatRPG/Data/Models/Campaign.cs +++ b/ChatRPG/Data/Models/Campaign.cs @@ -28,28 +28,4 @@ public Campaign(User user, string title, string startScenario) : this(user, titl public ICollection Characters { get; } = new List(); public ICollection Environments { get; } = new List(); public Character Player => Characters.First(c => c.IsPlayer); - - public Character InsertOrUpdateCharacter(Character character) - { - Character? existingChar = Characters.FirstOrDefault(c => c.Name.ToLower().Equals(character.Name.ToLower())); - if (existingChar != null) - { - existingChar.Description = character.Description; - return existingChar; - } - Characters.Add(character); - return character; - } - - public Environment InsertOrUpdateEnvironment(Environment environment) - { - Environment? existing = Environments.FirstOrDefault(e => e.Name.ToLower().Equals(environment.Name.ToLower())); - if (existing != null) - { - existing.Description = environment.Description; - return existing; - } - Environments.Add(environment); - return environment; - } } diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index 1e62a96..1ee47ea 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -67,8 +67,8 @@ public async Task UpdateCampaignFromNarrative(Campaign campaign, string narrativ environments.Append("\n]}"); - var agent = new ReActAgentChain(llm, _updateCampaignPrompt, characters.ToString(), environments.ToString(), - gameSummary: campaign.GameSummary); + var agent = new ReActAgentChain(llm, _updateCampaignPrompt, characters.ToString(), campaign.Player.Name, + environments.ToString(), gameSummary: campaign.GameSummary); var tools = CreateTools(campaign); foreach (var tool in tools) @@ -94,22 +94,27 @@ private List CreateTools(Campaign campaign) "LargeCreature, Monster}, and state is one of the following: {Dead, Unconscious, HeavilyWounded, " + "LightlyWounded, Healthy}. The description of a character could describe their physical characteristics, " + "personality, what they are known for, or other cool descriptive features. " + - "The tool should only be used once per character per narrative."); + "The tool should only be used once per character."); tools.Add(updateCharacterTool); - + var updateEnvironmentTool = new UpdateEnvironmentTool(campaign, "updateenvironmenttool", - "This tool must be used to create a new environment or update an existing environment in the campaign. " + - "Example: The narrative text mentions a new environment or contains changes to an existing environment. " + - "Input to this tool must be in the following RAW JSON format: {\"name\": \"\", " + - "\"description\": \"\"}, where the description of an environment " + - "could describe its physical characteristics, the creatures that inhabit it, the weather, or other cool " + - "descriptive features. The tool should only be used once per environment per narrative."); + "This tool must be used to create a new environment or update an existing environment in the " + + "campaign. Example: The narrative text mentions a new environment or contains changes to an existing " + + "environment. An environment refers to a place, location, or area that is well enough defined that it " + + "warrants its own description. Such a place could be a landmark with its own history, a building where " + + "story events take place, or a larger place like a magical forest. Input to this tool must be in the " + + "following RAW JSON format: {\"name\": \"\", \"description\": \"\", \"isPlayerHere\": }, where the description of an environment could describe its physical " + + "characteristics, its significance, the creatures that inhabit it, the weather, or other cool " + + "descriptive features so that it gives the Player useful information about the places they travel to " + + "while keeping the locations' descriptions interesting, mysterious and engaging. " + + "The tool should only be used once per environment."); tools.Add(updateEnvironmentTool); return tools; } - public async Task StoreMessagesInCampaign(Campaign campaign, string playerInput, string assistantOutput) { var newMessages = new List diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index b5eacdb..045d374 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -19,7 +19,7 @@ "SystemPrompts": { "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} Previous tool steps: {history} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", - "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. Environments in the game: {environments}. Game summary: {summary} Previous tool steps: {history} New narrative message: {input}", + "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. The Player character is {player_character}. Environments in the game: {environments}. Game summary: {summary} Previous tool steps: {history} New narrative message: {input}", "CombatHitHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" }.", "CombatHitMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack will miss. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", "CombatMissHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done. Your job is to provide flavor text regarding this attack. The player's attack always misses. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", From e75c4d00b435863a9bcc20e082988271b813d470 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Thu, 31 Oct 2024 15:12:37 +0100 Subject: [PATCH 24/51] Please the lady --- ChatRPG/Services/GameStateManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index 1ee47ea..b6125a7 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -67,7 +67,7 @@ public async Task UpdateCampaignFromNarrative(Campaign campaign, string narrativ environments.Append("\n]}"); - var agent = new ReActAgentChain(llm, _updateCampaignPrompt, characters.ToString(), campaign.Player.Name, + var agent = new ReActAgentChain(llm, _updateCampaignPrompt, characters.ToString(), campaign.Player.Name, environments.ToString(), gameSummary: campaign.GameSummary); var tools = CreateTools(campaign); From f778bba1ad2a1f7c7855b17674b3633c37fac30f Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Thu, 31 Oct 2024 15:29:46 +0100 Subject: [PATCH 25/51] Remove FileUtility since we do not need file saves --- ChatRPG/Data/FileUtility.cs | 167 ---------------------------- ChatRPG/Data/IFileUtility.cs | 21 ---- ChatRPG/Pages/CampaignPage.razor.cs | 17 --- ChatRPG/appsettings.json | 1 - 4 files changed, 206 deletions(-) delete mode 100644 ChatRPG/Data/FileUtility.cs delete mode 100644 ChatRPG/Data/IFileUtility.cs diff --git a/ChatRPG/Data/FileUtility.cs b/ChatRPG/Data/FileUtility.cs deleted file mode 100644 index 99c9cc9..0000000 --- a/ChatRPG/Data/FileUtility.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.Text; - -namespace ChatRPG.Data; - -public class FileUtility : IFileUtility -{ - private readonly string _currentUser; - private readonly string _path; - private readonly string _saveDir; - - // Define "special" keywords for determining the author of a message. - private readonly string _playerKeyword = "#: "; - private readonly string _gameKeyword = "#: "; - - /// - /// Initializes a new instance of the FileUtility class with the specified user and save directory. - /// - /// The username of the current user. - /// The directory where conversation files are saved (default: "Saves/"). - public FileUtility(string currentUser, string saveDir = "Saves/") - { - _currentUser = currentUser; - _saveDir = Path.Join(AppDomain.CurrentDomain.BaseDirectory, saveDir); - _path = SetPath(DateTime.Now); - } - - /// - public async Task UpdateSaveFileAsync(MessagePair messages) - { - // According to .NET docs, you do not need to check if directory exists first - Directory.CreateDirectory(_saveDir); - - await using FileStream fs = - new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.None, bufferSize: 4096, - useAsync: true); - - byte[] encodedPlayerMessage = Encoding.Unicode.GetBytes(PrepareMessageForSave(messages.PlayerMessage, true)); - await fs.WriteAsync(encodedPlayerMessage, 0, encodedPlayerMessage.Length); - - byte[] encodedAssistantMessage = Encoding.Unicode.GetBytes(PrepareMessageForSave(messages.AssistantMessage)); - await fs.WriteAsync(encodedAssistantMessage, 0, encodedAssistantMessage.Length); - } - - /// - public async Task> GetMostRecentConversationAsync(string playerTag, string assistantTag) - { - string filePath = GetMostRecentFile(Directory.GetFiles(_saveDir, $"{_currentUser}*")); - return await GetConversationsStringFromSaveFileAsync(filePath, playerTag, assistantTag); - } - - /// - /// Prepares a message for saving by ensuring a newline character and specifying the author. - /// - /// The message to prepare for saving. - /// A boolean indicating if the message is from the player (default: false). - /// The prepared message with a newline character and author tag. - private string PrepareMessageForSave(string message, bool isPlayerMessage = false) - { - if (!message.EndsWith("\n")) - message += "\n"; - - message = isPlayerMessage ? $"{_playerKeyword}{message}" : $"{_gameKeyword}{message}"; - return message; - } - - /// - /// Reads a conversation string from a save file asynchronously and converts it into a list of messages. - /// - /// The path to the save file to read. - /// The tag to mark a player message. - /// The tag to mark an assistant message. - /// A list of messages extracted from the save file. - private async Task> GetConversationsStringFromSaveFileAsync(string path, string playerTag, - string assistantTag) - { - await using FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, - bufferSize: 4096, useAsync: true); - - StringBuilder sb = new StringBuilder(); - byte[] buffer = new byte[0x1000]; - int bytesRead; - while ((bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length)) != 0) - { - string stream = Encoding.Unicode.GetString(buffer, 0, bytesRead); - sb.Append(stream); - } - - return ConvertConversationStringToList(sb.ToString(), playerTag, assistantTag); - } - - /// - /// Gets the most recent file from a list of files based on their timestamps. - /// - /// An array of file paths to choose from. - /// The path to the most recent file. - private string GetMostRecentFile(string[] files) - { - DateTime mostRecent = new DateTime(1, 1, 1, 0, 0, 0); // Hello Jesus - string mostRecentPath = string.Empty; - foreach (string filename in files) - { - string[] splitName = Path.GetFileNameWithoutExtension(filename).Split(' '); - DateTime timestamp = DateTime.ParseExact(splitName[1], "dd-MM-yyyy_HH-mm-ss", - System.Globalization.CultureInfo.InvariantCulture); - if (DateTime.Compare(timestamp, mostRecent) > 0) - { - mostRecent = timestamp; - mostRecentPath = filename; - } - } - - return mostRecentPath; - } - - /// - /// This method converts a conversation in string form to a list of messages making up the conversation. - /// - /// A full conversation in string form. - /// The tag to mark a player message. - /// The tag to mark an assistant message. - /// The list of messages making up the conversation. - private List ConvertConversationStringToList(string conversation, string playerTag, string assistantTag) - { - // First split up conversation on all player keywords, - // which gives us the player query and all subsequent assistant responses - string[] playerSplit = conversation.Split(new[] { _playerKeyword, $"\n{_playerKeyword}" }, - StringSplitOptions.RemoveEmptyEntries); - - // Prepend "[playerTag]: " to all player queries - for (int i = 0; i < playerSplit.Length; i++) - { - playerSplit[i] = $"{playerTag}: {playerSplit[i]}"; - } - - // Secondly, split all individual player strings since they still contain assistant responses - List fullConversation = new List(); - foreach (string combinedMessage in playerSplit) - { - string[] messages = combinedMessage.Split(new[] { _gameKeyword, $"\n{_gameKeyword}" }, - StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < messages.Length; i++) - { - // Prepend "[assistantTag]: " to all assistant responses - if (!messages[i].StartsWith($"{playerTag}: ")) - { - messages[i] = $"{assistantTag}: {messages[i]}"; - } - - fullConversation.Add(messages[i]); - } - } - - return fullConversation; - } - - /// - /// Sets the path for a save file based on the user and a timestamp. - /// - /// The timestamp used to create the file name. - /// The complete path to the save file. - private string SetPath(DateTime timestamp) - { - // Add save directory and file name to path - string fileName = $"{_currentUser} {timestamp:dd-MM-yyyy_HH-mm-ss}.txt"; - return Path.Join(_saveDir, fileName); - } -} diff --git a/ChatRPG/Data/IFileUtility.cs b/ChatRPG/Data/IFileUtility.cs deleted file mode 100644 index 1473cc0..0000000 --- a/ChatRPG/Data/IFileUtility.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace ChatRPG.Data; - -public interface IFileUtility -{ - /// - /// Updates the conversation save file asynchronously with the provided message pair. - /// - /// A pair of messages (player and assistant) to save in the file. - /// A task representing the asynchronous file update process. - Task UpdateSaveFileAsync(MessagePair messages); - /// - /// Retrieves the most recent conversation from a save file asynchronously, parsing and inserting - /// into messages the player and assistant tags. - /// - /// The tag to mark a player message. - /// The tag to mark an assistant message. - /// A list of messages representing the most recent conversation. - Task> GetMostRecentConversationAsync(string playerTag, string assistantTag); -} - -public record MessagePair(string PlayerMessage, string AssistantMessage); diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 7e2a482..9da4087 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -1,4 +1,3 @@ -using ChatRPG.Data; using ChatRPG.Services; using ChatRPG.Data.Models; using ChatRPG.Services.Events; @@ -14,15 +13,12 @@ namespace ChatRPG.Pages; public partial class CampaignPage { private string? _loggedInUsername; - private bool _shouldSave; private IJSObjectReference? _scrollJsScript; private IJSObjectReference? _detectScrollBarJsScript; private bool _hasScrollBar = false; - private FileUtility? _fileUtil; private List _conversation = new(); private string _userInput = ""; private bool _isWaitingForResponse; - private OpenAiGptMessage? _latestPlayerMessage; private const string BottomId = "bottom-id"; private Campaign? _campaign; private List _npcList = new(); @@ -41,7 +37,6 @@ public partial class CampaignPage ? "margin-top: -25px; margin-bottom: -60px;" : "margin-top: 20px; margin-bottom: 60px;"; - [Inject] private IConfiguration? Configuration { get; set; } [Inject] private IJSRuntime? JsRuntime { get; set; } [Inject] private AuthenticationStateProvider? AuthenticationStateProvider { get; set; } [Inject] private IPersistenceService? PersistenceService { get; set; } @@ -77,8 +72,6 @@ protected override async Task OnInitializedAsync() .ToList(); } - if (_loggedInUsername != null) _fileUtil = new FileUtility(_loggedInUsername); - _shouldSave = Configuration!.GetValue("SaveConversationsToFile"); GameInputHandler!.ChatCompletionReceived += OnChatCompletionReceived; GameInputHandler!.ChatCompletionChunkReceived += OnChatCompletionChunkReceived; if (_conversation.Count == 0) @@ -157,7 +150,6 @@ private async Task SendPrompt() _isWaitingForResponse = true; OpenAiGptMessage userInput = new(MessageRole.User, _userInput, _activeUserPromptType); _conversation.Add(userInput); - _latestPlayerMessage = userInput; _userInput = string.Empty; await ScrollToElement(BottomId); try @@ -200,7 +192,6 @@ private async Task ScrollToElement(string elementId) private void OnChatCompletionReceived(object? sender, ChatCompletionReceivedEventArgs eventArgs) { _conversation.Add(eventArgs.Message); - UpdateSaveFile(eventArgs.Message.Content); Task.Run(() => ScrollToElement(BottomId)); if (eventArgs.Message.Content != string.Empty) { @@ -220,7 +211,6 @@ private void OnChatCompletionChunkReceived(object? sender, ChatCompletionChunkRe if (eventArgs.IsStreamingDone) { _isWaitingForResponse = false; - UpdateSaveFile(message.Content); StateHasChanged(); } else if (eventArgs.Chunk is not null) @@ -232,13 +222,6 @@ private void OnChatCompletionChunkReceived(object? sender, ChatCompletionChunkRe Task.Run(() => ScrollToElement(BottomId)); } - private void UpdateSaveFile(string asstMessage) - { - if (!_shouldSave || _fileUtil == null || string.IsNullOrEmpty(asstMessage)) return; - MessagePair messagePair = new MessagePair(_latestPlayerMessage?.Content ?? "", asstMessage); - Task.Run(() => _fileUtil.UpdateSaveFileAsync(messagePair)); - } - private void OnPromptTypeChange(UserPromptType type) { switch (type) diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 045d374..c882f10 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -12,7 +12,6 @@ } }, "AllowedHosts": "*", - "SaveConversationsToFile": true, "StreamChatCompletions": true, "UseMocks": false, "ShouldSendEmails": true, From d2c0fdccb5c839f64d24f88ea6a2f6c23bf4ea43 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 1 Nov 2024 12:51:07 +0100 Subject: [PATCH 26/51] BattleTool has been implemented and we provide an option to not summarize the narrative, instead sending the entire message collection --- ChatRPG/API/ReActAgentChain.cs | 6 +- ChatRPG/API/ReActLlmClient.cs | 29 ++- ChatRPG/API/Tools/BattleInput.cs | 51 +++++ ChatRPG/API/Tools/BattleTool.cs | 183 ++++++++++++++++++ ChatRPG/API/Tools/CharacterInput.cs | 22 +++ ...nvironmentInput.cs => EnvironmentInput.cs} | 2 +- ChatRPG/API/Tools/ToolUtilities.cs | 2 +- ChatRPG/API/Tools/UpdateCharacterInput.cs | 9 - ChatRPG/API/Tools/UpdateCharacterTool.cs | 2 +- ChatRPG/API/Tools/UpdateEnvironmentTool.cs | 2 +- ChatRPG/API/Tools/WoundCharacterTool.cs | 6 +- ChatRPG/Data/Models/CharacterType.cs | 2 +- ChatRPG/Services/GameStateManager.cs | 22 ++- ChatRPG/appsettings.json | 9 +- 14 files changed, 312 insertions(+), 35 deletions(-) create mode 100644 ChatRPG/API/Tools/BattleInput.cs create mode 100644 ChatRPG/API/Tools/BattleTool.cs create mode 100644 ChatRPG/API/Tools/CharacterInput.cs rename ChatRPG/API/Tools/{UpdateEnvironmentInput.cs => EnvironmentInput.cs} (81%) delete mode 100644 ChatRPG/API/Tools/UpdateCharacterInput.cs diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index 22ea617..1697db4 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -79,7 +79,7 @@ public ReActAgentChain( string? gameSummary = null, string inputKey = "input", string outputKey = "text", - int maxActions = 10) + int maxActions = 20) { _model = model; _model.Settings!.StopSequences = ["Observation", "[END]"]; @@ -116,7 +116,7 @@ public ReActAgentChain( string? gameSummary = null, string inputKey = "input", string outputKey = "text", - int maxActions = 10) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) + int maxActions = 20) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) { _actionPrompt = actionPrompt ?? string.Empty; } @@ -130,7 +130,7 @@ public ReActAgentChain( string? gameSummary = null, string inputKey = "input", string outputKey = "text", - int maxActions = 10) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) + int maxActions = 20) : this(model, reActPrompt, gameSummary, inputKey, outputKey, maxActions) { _characters = characters; _playerCharacter = playerCharacter; diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 125f720..03d5f25 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -97,9 +97,32 @@ private List CreateTools(Campaign campaign) "Do not use markdown, only raw JSON as input. Use this tool only once per character at most."); tools.Add(healCharacterTool); - // Use battle when an attack can be mitigated or dodged by the involved participants. - // This tool is appropriate for combat, battle between multiple participants, - // or attacks that can be avoided and a to-hit roll would be needed in order to determine a hit. + var battleTool = new BattleTool(_configuration, campaign, utils, "battletool", + "Use the battle tool to resolve battle or combat between two participants. If there are more " + + "than two participants, the tool must be used once per attacker to give everyone a chance at fighting. " + + "The battle tool will give each participant a chance to fight the other participant. The tool should " + + "also be used when an attack can be mitigated or dodged by the involved participants. A hit chance " + + "specifier will help adjust the chance that a participant gets to retaliate. Example: There are " + + "three combatants, the Player's character and two assassins. The battle tool is called first with the " + + "Player's character as participant one and one of the assassins as participant two. Chances are high " + + "that the player will hit the assassin but assassins must be precise, making it harder to hit, however, " + + "they deal high damage if they hit. We observe that the participant one hits participant two and " + + "participant two misses participant one. After this round of battle has been resolved, call the tool again " + + "with the Player's character as participant one and the other assassin as participant two. Since " + + "participant one in this case has already hit once during this narrative, we impose a penalty to their " + + "hit chance, which is accumulative for each time they hit an enemy during battle. The damage severity " + + "describes how powerful the attack is which is derived from the narrative description of the attacks. " + + "If there are no direct description, estimate the impact of an attack based on the character type and " + + "their description. Input to this tool must be in the following RAW JSON format: {{\"name\": " + + "\"\", \"description\": \"\"}, {\"name\": " + + "\"\", \"description\": \"\"}, " + + "\"participant1HitChance\": \"\", \"participant2HitChance\": " + + "\"\", \"participant1DamageSeverity\": " + + "\"\", \"participant2DamageSeverity\": " + + "\"\"} where participant#HitChance specifiers are one " + + "of the following {high, medium, low, impossible} and participant#DamageSeverity is one of " + + "the following {low, medium, high, extraordinary}. Do not use markdown, only raw JSON as input."); + tools.Add(battleTool); return tools; } diff --git a/ChatRPG/API/Tools/BattleInput.cs b/ChatRPG/API/Tools/BattleInput.cs new file mode 100644 index 0000000..dd41c11 --- /dev/null +++ b/ChatRPG/API/Tools/BattleInput.cs @@ -0,0 +1,51 @@ +namespace ChatRPG.API.Tools; + +public class BattleInput +{ + private static readonly HashSet ValidChancesToHit = ["high", "medium", "low", "impossible"]; + private static readonly HashSet ValidDamageSeverities = ["low", "medium", "high", "extraordinary"]; + + public CharacterInput? Participant1 { get; set; } + public CharacterInput? Participant2 { get; set; } + public string? Participant1ChanceToHit { get; set; } + public string? Participant2ChanceToHit { get; set; } + public string? Participant1DamageSeverity { get; set; } + public string? Participant2DamageSeverity { get; set; } + + public bool IsValid(out List validationErrors) + { + validationErrors = []; + + if (Participant1 == null) + { + validationErrors.Add("Participant1 is required."); + } + else if (!Participant1.IsValidForBattle(out var participant1Errors)) + { + validationErrors.AddRange(participant1Errors.Select(e => $"Participant1: {e}")); + } + + if (Participant2 == null) + { + validationErrors.Add("Participant2 is required."); + } + else if (!Participant2.IsValidForBattle(out var participant2Errors)) + { + validationErrors.AddRange(participant2Errors.Select(e => $"Participant2: {e}")); + } + + if (Participant1ChanceToHit != null && !ValidChancesToHit.Contains(Participant1ChanceToHit)) + validationErrors.Add("Participant1ChanceToHit must be one of the following: high, medium, low, impossible."); + + if (Participant2ChanceToHit != null && !ValidChancesToHit.Contains(Participant2ChanceToHit)) + validationErrors.Add("Participant2ChanceToHit must be one of the following: high, medium, low, impossible."); + + if (Participant1DamageSeverity != null && !ValidDamageSeverities.Contains(Participant1DamageSeverity)) + validationErrors.Add("Participant1DamageSeverity must be one of the following: low, medium, high, extraordinary."); + + if (Participant2DamageSeverity != null && !ValidDamageSeverities.Contains(Participant2DamageSeverity)) + validationErrors.Add("Participant2DamageSeverity must be one of the following: low, medium, high, extraordinary."); + + return validationErrors.Count == 0; + } +} diff --git a/ChatRPG/API/Tools/BattleTool.cs b/ChatRPG/API/Tools/BattleTool.cs new file mode 100644 index 0000000..c1259cf --- /dev/null +++ b/ChatRPG/API/Tools/BattleTool.cs @@ -0,0 +1,183 @@ +using System.Text; +using System.Text.Json; +using ChatRPG.Data.Models; +using LangChain.Chains.StackableChains.Agents.Tools; + +namespace ChatRPG.API.Tools; + +public class BattleTool( + IConfiguration configuration, + Campaign campaign, + ToolUtilities utilities, + string name, + string? description = null) : AgentTool(name, description) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly Dictionary HitChance = new() + { + { "high", 0.9 }, + { "medium", 0.5 }, + { "low", 0.3 }, + { "impossible", 0.01 } + }; + + private static readonly Dictionary DamageRanges = new() + { + { "low", (5, 10) }, + { "medium", (10, 20) }, + { "high", (15, 25) }, + { "extraordinary", (25, 80) } + }; + + public override async Task ToolTask(string input, CancellationToken token = new CancellationToken()) + { + try + { + var battleInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + throw new JsonException("Failed to deserialize"); + var instruction = configuration.GetSection("SystemPrompts").GetValue("BattleInstruction")!; + + if (!battleInput.IsValid(out var errors)) + { + var errorMessage = new StringBuilder(); + errorMessage.Append( + "Invalid input provided for the battle. Please provide valid input and correct the following errors:\n"); + foreach (var validationError in errors) + { + errorMessage.Append(validationError + "\n"); + } + + return errorMessage.ToString(); + } + + var participant1 = await utilities.FindCharacter(campaign, + $"{{\"name\": {battleInput.Participant1!.Name}, " + + $"\"description\": {battleInput.Participant1.Description}}}", instruction); + var participant2 = await utilities.FindCharacter(campaign, + $"{{\"name\": {battleInput.Participant2!.Name}, " + + $"\"description\": {battleInput.Participant2.Description}}}", instruction); + + if (participant1 is null && participant2 is null) + { + return $"Could not determine the characters corresponding to {battleInput.Participant1.Name} and " + + $"{battleInput.Participant2.Name}. The characters do not exist in the game. " + + $"Consider creating the characters before calling the battle tool."; + } + + if (participant1 is null) + { + return $"Could not determine the character corresponding to {battleInput.Participant1.Name}. " + + $"The character does not exist in the game. Consider creating the character before calling the battle tool."; + } + + if (participant2 is null) + { + return $"Could not determine the character corresponding to {battleInput.Participant2.Name}. " + + $"The character does not exist in the game. Consider creating the character before calling the battle tool."; + } + + var firstHitter = DetermineFirstHitter(participant1, participant2); + + Character secondHitter; + string firstHitChance; + string secondHitChance; + string firstHitSeverity; + string secondHitSeverity; + + if (firstHitter == participant1) + { + secondHitter = participant2; + firstHitChance = battleInput.Participant1ChanceToHit!; + secondHitChance = battleInput.Participant2ChanceToHit!; + firstHitSeverity = battleInput.Participant1DamageSeverity!; + secondHitSeverity = battleInput.Participant2DamageSeverity!; + } + else + { + secondHitter = participant1; + firstHitChance = battleInput.Participant2ChanceToHit!; + secondHitChance = battleInput.Participant1ChanceToHit!; + firstHitSeverity = battleInput.Participant2DamageSeverity!; + secondHitSeverity = battleInput.Participant1DamageSeverity!; + } + + return ResolveCombat(firstHitter, secondHitter, firstHitChance, secondHitChance, firstHitSeverity, + secondHitSeverity); + } + catch (Exception) + { + return + "Could not execute the battle. Tool input format was invalid. " + + "Please provide the input in valid JSON without markdown."; + } + } + + private static string ResolveCombat(Character firstHitter, Character secondHitter, string firstHitChance, + string secondHitChance, string firstHitSeverity, string secondHitSeverity) + { + var resultString = string.Empty; + + resultString += ResolveAttack(firstHitter, secondHitter, firstHitChance, firstHitSeverity); + if (secondHitter.CurrentHealth <= 0) + { + return resultString; + } + + resultString += ResolveAttack(secondHitter, firstHitter, secondHitChance, secondHitSeverity); + + return resultString; + } + + private static string ResolveAttack(Character damageDealer, Character damageTaker, string hitChance, + string hitSeverity) + { + var resultString = string.Empty; + Random rand = new Random(); + var doesAttackHit = rand.NextDouble() <= HitChance[hitChance]; + + if (doesAttackHit) + { + var (minDamage, maxDamage) = DamageRanges[hitSeverity]; + var damage = rand.Next(minDamage, maxDamage); + resultString += $"{damageDealer.Name} deals {damage} damage to {damageTaker.Name}."; + if (damageTaker.AdjustHealth(-damage)) + { + if (damageTaker.IsPlayer) + { + return resultString + $"The player {damageTaker.Name} has no remaining health points. " + + "Their adventure is over. No more actions can be taken."; + } + + return resultString + + $"With no health points remaining, {damageTaker.Name} dies and can no longer " + + "perform actions in the narrative."; + } + + return resultString + + $"They have {damageTaker.CurrentHealth} health points out of {damageTaker.MaxHealth} remaining."; + } + + return $"{damageDealer.Name} misses their attack on {damageTaker.Name}."; + } + + private static Character DetermineFirstHitter(Character participant1, Character participant2) + { + var rand = new Random(); + var firstHitRoll = rand.NextDouble(); + + return (participant1.Type - participant2.Type) switch + { + 0 => firstHitRoll <= 0.5 ? participant1 : participant2, + 1 => firstHitRoll <= 0.4 ? participant1 : participant2, + 2 => firstHitRoll <= 0.3 ? participant1 : participant2, + >= 3 => firstHitRoll <= 0.2 ? participant1 : participant2, + -1 => firstHitRoll <= 0.6 ? participant1 : participant2, + -2 => firstHitRoll <= 0.7 ? participant1 : participant2, + <= -3 => firstHitRoll <= 0.8 ? participant1 : participant2 + }; + } +} diff --git a/ChatRPG/API/Tools/CharacterInput.cs b/ChatRPG/API/Tools/CharacterInput.cs new file mode 100644 index 0000000..ccea124 --- /dev/null +++ b/ChatRPG/API/Tools/CharacterInput.cs @@ -0,0 +1,22 @@ +namespace ChatRPG.API.Tools; + +public class CharacterInput +{ + public string? Name { get; set; } + public string? Description { get; set; } + public string? Type { get; set; } + public string? State { get; set; } + + public bool IsValidForBattle(out List validationErrors) + { + validationErrors = []; + + if (string.IsNullOrWhiteSpace(Name)) + validationErrors.Add("Name is required."); + + if (string.IsNullOrWhiteSpace(Description)) + validationErrors.Add("Description is required."); + + return validationErrors.Count == 0; + } +} diff --git a/ChatRPG/API/Tools/UpdateEnvironmentInput.cs b/ChatRPG/API/Tools/EnvironmentInput.cs similarity index 81% rename from ChatRPG/API/Tools/UpdateEnvironmentInput.cs rename to ChatRPG/API/Tools/EnvironmentInput.cs index 1231570..6ec693f 100644 --- a/ChatRPG/API/Tools/UpdateEnvironmentInput.cs +++ b/ChatRPG/API/Tools/EnvironmentInput.cs @@ -1,6 +1,6 @@ namespace ChatRPG.API.Tools; -public class UpdateEnvironmentInput +public class EnvironmentInput { public string? Name { get; set; } public string? Description { get; set; } diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 14e280f..4b204fd 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -30,7 +30,7 @@ public class ToolUtilities(IConfiguration configuration) query.Append($"\n\nThe story up until now: {campaign.GameSummary}"); - query.Append($"\n\nThe player's newest action: {input}"); + query.Append($"\n\nFind the character using the following content: {input}"); query.Append("\n\nHere is the list of all characters present in the story:\n\n{\"characters\": [\n"); diff --git a/ChatRPG/API/Tools/UpdateCharacterInput.cs b/ChatRPG/API/Tools/UpdateCharacterInput.cs deleted file mode 100644 index 17eaf9a..0000000 --- a/ChatRPG/API/Tools/UpdateCharacterInput.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ChatRPG.API.Tools; - -public class UpdateCharacterInput -{ - public string? Name { get; set; } - public string? Description { get; set; } - public string? Type { get; set; } - public string? State { get; set; } -} diff --git a/ChatRPG/API/Tools/UpdateCharacterTool.cs b/ChatRPG/API/Tools/UpdateCharacterTool.cs index 999b969..79089db 100644 --- a/ChatRPG/API/Tools/UpdateCharacterTool.cs +++ b/ChatRPG/API/Tools/UpdateCharacterTool.cs @@ -19,7 +19,7 @@ public class UpdateCharacterTool( { try { - var updateCharacterInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var updateCharacterInput = JsonSerializer.Deserialize(input, JsonOptions) ?? throw new JsonException("Failed to deserialize"); if (updateCharacterInput.Name.IsNullOrEmpty()) { diff --git a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs index b7e8b8d..10a0145 100644 --- a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs +++ b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs @@ -20,7 +20,7 @@ public class UpdateEnvironmentTool( { try { - var updateEnvironmentInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var updateEnvironmentInput = JsonSerializer.Deserialize(input, JsonOptions) ?? throw new JsonException("Failed to deserialize"); if (updateEnvironmentInput.Name.IsNullOrEmpty()) diff --git a/ChatRPG/API/Tools/WoundCharacterTool.cs b/ChatRPG/API/Tools/WoundCharacterTool.cs index 3880dfc..e37baac 100644 --- a/ChatRPG/API/Tools/WoundCharacterTool.cs +++ b/ChatRPG/API/Tools/WoundCharacterTool.cs @@ -29,11 +29,11 @@ public class WoundCharacterTool( { try { - var effectInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var woundInput = JsonSerializer.Deserialize(input, JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("WoundCharacterInstruction")!; - var character = await utilities.FindCharacter(campaign, effectInput.Input!, instruction); + var character = await utilities.FindCharacter(campaign, woundInput.Input!, instruction); if (character is null) { @@ -43,7 +43,7 @@ public class WoundCharacterTool( // Determine damage Random rand = new Random(); - var (minDamage, maxDamage) = DamageRanges[effectInput.Severity!]; + var (minDamage, maxDamage) = DamageRanges[woundInput.Severity!]; var damage = rand.Next(minDamage, maxDamage); if (character.AdjustHealth(-damage)) diff --git a/ChatRPG/Data/Models/CharacterType.cs b/ChatRPG/Data/Models/CharacterType.cs index 96df948..463090f 100644 --- a/ChatRPG/Data/Models/CharacterType.cs +++ b/ChatRPG/Data/Models/CharacterType.cs @@ -2,8 +2,8 @@ public enum CharacterType { - Humanoid, SmallCreature, + Humanoid, LargeCreature, Monster } diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index b6125a7..37afe66 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -15,18 +15,20 @@ public class GameStateManager { private readonly OpenAiProvider _provider; private readonly IPersistenceService _persistenceService; - private readonly IConfiguration _configuration; private readonly string _updateCampaignPrompt; + private readonly bool _summarizeMessages; public GameStateManager(IConfiguration configuration, IPersistenceService persistenceService) { - _configuration = configuration; ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("ApiKeys").GetValue("OpenAI")); ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts") .GetValue("UpdateCampaignFromNarrative")); + ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts") + .GetValue("ShouldSummarize")); _provider = new OpenAiProvider(configuration.GetSection("ApiKeys").GetValue("OpenAI")!); _updateCampaignPrompt = configuration.GetSection("SystemPrompts").GetValue("UpdateCampaignFromNarrative")!; + _summarizeMessages = configuration.GetSection("SystemPrompts").GetValue("ShouldSummarize"); _persistenceService = persistenceService; } @@ -80,17 +82,16 @@ public async Task UpdateCampaignFromNarrative(Campaign campaign, string narrativ await chain.RunAsync("text"); } - private List CreateTools(Campaign campaign) + private static List CreateTools(Campaign campaign) { var tools = new List(); - var utils = new ToolUtilities(_configuration); var updateCharacterTool = new UpdateCharacterTool(campaign, "updatecharactertool", "This tool must be used to create a new character or update an existing character in the campaign. " + "Example: The narrative text mentions a new character or contains changes to an existing character. " + "Input to this tool must be in the following RAW JSON format: {\"name\": \"\", " + "\"description\": \"\", \"type\": \"\", " + - "\"state\": \"\"}, where type is one of the following: {Humanoid, SmallCreature, " + + "\"state\": \"\"}, where type is one of the following: {SmallCreature, Humanoid, " + "LargeCreature, Monster}, and state is one of the following: {Dead, Unconscious, HeavilyWounded, " + "LightlyWounded, Healthy}. The description of a character could describe their physical characteristics, " + "personality, what they are known for, or other cool descriptive features. " + @@ -128,7 +129,16 @@ public async Task StoreMessagesInCampaign(Campaign campaign, string playerInput, Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } }; - campaign.GameSummary = await summaryLlm.SummarizeAsync(newMessages, campaign.GameSummary ?? ""); + if (_summarizeMessages) + { + campaign.GameSummary = await summaryLlm.SummarizeAsync(newMessages, campaign.GameSummary ?? ""); + } + else + { + campaign.GameSummary ??= string.Empty; + campaign.GameSummary += string.Join("\n", newMessages.Select(m => m.Content)) + "\n"; + } + foreach (var message in newMessages) { // Only add the message, if the list is empty. diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index c882f10..2d3662b 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -12,6 +12,7 @@ } }, "AllowedHosts": "*", + "ShouldSummarize": true, "StreamChatCompletions": true, "UseMocks": false, "ShouldSendEmails": true, @@ -19,15 +20,11 @@ "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} Previous tool steps: {history} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. The Player character is {player_character}. Environments in the game: {environments}. Game summary: {summary} Previous tool steps: {history} New narrative message: {input}", - "CombatHitHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent, if any\" }.", - "CombatHitMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done and the damage it will deal. Your job is to provide flavor text regarding this attack, including the damage dealt. The player's attack always hits. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack will miss. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", - "CombatMissHit": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done. Your job is to provide flavor text regarding this attack. The player's attack always misses. You should afterward provide flavor text regarding the opponent's attack towards the player, including the damage dealt. The opponent's attack always hits. The damage of the opponent's attack is also provided. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", - "CombatMissMiss": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. You will be given information about an attack that the player has done. Your job is to provide flavor text regarding this attack. The player's attack always misses. You should afterward provide flavor text regarding the opponent's attack towards the player. The opponent's attack always misses. For both flavor texts, you may utilize the information in the provided conversation. Your response should account for how large of a ratio the damage dealt is compared to the opponent's current health points. For example, if the opponent's current health points are high, they will not be heavily wounded by low damage. You should also account for the ratio of current health points to maximum health points for both the player and the opponent when describing their behavior. Address the player in the second person. Always respond in valid JSON, and in this exact structure: { \"narrative\": \"\", \"characters\": [], \"environment\": {}, \"opponent\": \"name of current opponent\" }.", - "CombatOpponentDescription": "You are an expert game master in a single-player RPG. The player is in combat with an opponent. The player has just attacked someone. Your job is to determine who the player is attacking. Always respond in valid JSON, and in this exact structure: { \"opponent\": \"name of current opponent\", \"characters\": [] }, where \"characters\" includes whoever the user is attacking if they have not previously appeared in the narrative, describing them concisely here in this exact way: { \"name\": \"Name of the character\", \"description\": \"Short description\", \"type\": \"Humanoid, SmallCreature, LargeCreature or Monster\" }.", "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction}. Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object.", "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury", - "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting" + "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting", + "BattleInstruction": "Find the character that will be involved in a battle or combat" } } From 4a31ad0e6bed9d87a32c085e0a3536a4a5893f29 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 1 Nov 2024 14:14:00 +0100 Subject: [PATCH 27/51] Create dummy character in battle and pray archivst is smart --- ChatRPG/API/ReActLlmClient.cs | 32 +++++++++++++---------- ChatRPG/API/Tools/BattleInput.cs | 8 +++--- ChatRPG/API/Tools/BattleTool.cs | 39 ++++++++++------------------ ChatRPG/Services/GameStateManager.cs | 2 -- 4 files changed, 36 insertions(+), 45 deletions(-) diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 03d5f25..ba5b5ec 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -1,6 +1,7 @@ using ChatRPG.API.Tools; using ChatRPG.Data.Models; using LangChain.Chains.StackableChains.Agents.Tools; +using LangChain.Providers; using LangChain.Providers.OpenAI; using LangChain.Providers.OpenAI.Predefined; using static LangChain.Chains.Chain; @@ -48,7 +49,7 @@ public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign ca }; var eventProcessor = new LlmEventProcessor(agentLlm); - var agent = new ReActAgentChain(agentLlm, _reActPrompt, actionPrompt: actionPrompt, campaign.GameSummary); + var agent = new ReActAgentChain(agentLlm.UseConsoleForDebug(), _reActPrompt, actionPrompt: actionPrompt, campaign.GameSummary); var tools = CreateTools(campaign); foreach (var tool in tools) { @@ -81,7 +82,8 @@ private List CreateTools(Campaign campaign) "Input to this tool must be in the following RAW JSON format: {\"input\": \"The player's input\", " + "\"severity\": \"Describes how devastating the injury to the character will be based on the action. " + "Can be one of the following values: {low, medium, high, extraordinary}}\". Do not use markdown, " + - "only raw JSON as input. Use this tool only once per character at most."); + "only raw JSON as input. Use this tool only once per character at most and only if they are not engaged " + + "in battle."); tools.Add(woundCharacterTool); var healCharacterTool = new HealCharacterTool(_configuration, campaign, utils, "healcharactertool", @@ -101,17 +103,18 @@ private List CreateTools(Campaign campaign) "Use the battle tool to resolve battle or combat between two participants. If there are more " + "than two participants, the tool must be used once per attacker to give everyone a chance at fighting. " + "The battle tool will give each participant a chance to fight the other participant. The tool should " + - "also be used when an attack can be mitigated or dodged by the involved participants. A hit chance " + - "specifier will help adjust the chance that a participant gets to retaliate. Example: There are " + - "three combatants, the Player's character and two assassins. The battle tool is called first with the " + - "Player's character as participant one and one of the assassins as participant two. Chances are high " + - "that the player will hit the assassin but assassins must be precise, making it harder to hit, however, " + - "they deal high damage if they hit. We observe that the participant one hits participant two and " + - "participant two misses participant one. After this round of battle has been resolved, call the tool again " + - "with the Player's character as participant one and the other assassin as participant two. Since " + - "participant one in this case has already hit once during this narrative, we impose a penalty to their " + - "hit chance, which is accumulative for each time they hit an enemy during battle. The damage severity " + - "describes how powerful the attack is which is derived from the narrative description of the attacks. " + + "also be used when an attack can be mitigated or dodged by the involved participants. It is also " + + "possible for either or both participants to miss. A hit chance specifier will help adjust the chance " + + "that a participant gets to retaliate. Example: There are three combatants, the Player's character " + + "and two assassins. The battle tool is called first with the Player's character as participant one and " + + "one of the assassins as participant two. Chances are high that the player will hit the assassin but " + + "assassins must be precise, making it harder to hit, however, they deal high damage if they hit. We " + + "observe that the participant one hits participant two and participant two misses participant one. " + + "After this round of battle has been resolved, call the tool again with the Player's character as " + + "participant one and the other assassin as participant two. Since participant one in this case has " + + "already hit once during this narrative, we impose a penalty to their hit chance, which is " + + "accumulative for each time they hit an enemy during battle. The damage severity describes how " + + "powerful the attack is which is derived from the narrative description of the attacks. " + "If there are no direct description, estimate the impact of an attack based on the character type and " + "their description. Input to this tool must be in the following RAW JSON format: {{\"name\": " + "\"\", \"description\": \"\"}, {\"name\": " + @@ -121,7 +124,8 @@ private List CreateTools(Campaign campaign) "\"\", \"participant2DamageSeverity\": " + "\"\"} where participant#HitChance specifiers are one " + "of the following {high, medium, low, impossible} and participant#DamageSeverity is one of " + - "the following {low, medium, high, extraordinary}. Do not use markdown, only raw JSON as input."); + "the following {low, medium, high, extraordinary}. Do not use markdown, only raw JSON as input. " + + "The narrative battle is over when each character has had the chance to attack another character at most once."); tools.Add(battleTool); return tools; diff --git a/ChatRPG/API/Tools/BattleInput.cs b/ChatRPG/API/Tools/BattleInput.cs index dd41c11..a537b04 100644 --- a/ChatRPG/API/Tools/BattleInput.cs +++ b/ChatRPG/API/Tools/BattleInput.cs @@ -7,8 +7,8 @@ public class BattleInput public CharacterInput? Participant1 { get; set; } public CharacterInput? Participant2 { get; set; } - public string? Participant1ChanceToHit { get; set; } - public string? Participant2ChanceToHit { get; set; } + public string? Participant1HitChance { get; set; } + public string? Participant2HitChance { get; set; } public string? Participant1DamageSeverity { get; set; } public string? Participant2DamageSeverity { get; set; } @@ -34,10 +34,10 @@ public bool IsValid(out List validationErrors) validationErrors.AddRange(participant2Errors.Select(e => $"Participant2: {e}")); } - if (Participant1ChanceToHit != null && !ValidChancesToHit.Contains(Participant1ChanceToHit)) + if (Participant1HitChance != null && !ValidChancesToHit.Contains(Participant1HitChance)) validationErrors.Add("Participant1ChanceToHit must be one of the following: high, medium, low, impossible."); - if (Participant2ChanceToHit != null && !ValidChancesToHit.Contains(Participant2ChanceToHit)) + if (Participant2HitChance != null && !ValidChancesToHit.Contains(Participant2HitChance)) validationErrors.Add("Participant2ChanceToHit must be one of the following: high, medium, low, impossible."); if (Participant1DamageSeverity != null && !ValidDamageSeverities.Contains(Participant1DamageSeverity)) diff --git a/ChatRPG/API/Tools/BattleTool.cs b/ChatRPG/API/Tools/BattleTool.cs index c1259cf..de18409 100644 --- a/ChatRPG/API/Tools/BattleTool.cs +++ b/ChatRPG/API/Tools/BattleTool.cs @@ -61,24 +61,11 @@ public class BattleTool( $"{{\"name\": {battleInput.Participant2!.Name}, " + $"\"description\": {battleInput.Participant2.Description}}}", instruction); - if (participant1 is null && participant2 is null) - { - return $"Could not determine the characters corresponding to {battleInput.Participant1.Name} and " + - $"{battleInput.Participant2.Name}. The characters do not exist in the game. " + - $"Consider creating the characters before calling the battle tool."; - } - - if (participant1 is null) - { - return $"Could not determine the character corresponding to {battleInput.Participant1.Name}. " + - $"The character does not exist in the game. Consider creating the character before calling the battle tool."; - } - - if (participant2 is null) - { - return $"Could not determine the character corresponding to {battleInput.Participant2.Name}. " + - $"The character does not exist in the game. Consider creating the character before calling the battle tool."; - } + // Create dummy characters if they do not exist and pray that the archive chain will update them + participant1 ??= new Character(campaign, campaign.Player.Environment, CharacterType.Humanoid, + battleInput.Participant1.Name!, battleInput.Participant1.Description!, false); + participant2 ??= new Character(campaign, campaign.Player.Environment, CharacterType.Humanoid, + battleInput.Participant2.Name!, battleInput.Participant2.Description!, false); var firstHitter = DetermineFirstHitter(participant1, participant2); @@ -91,16 +78,16 @@ public class BattleTool( if (firstHitter == participant1) { secondHitter = participant2; - firstHitChance = battleInput.Participant1ChanceToHit!; - secondHitChance = battleInput.Participant2ChanceToHit!; + firstHitChance = battleInput.Participant1HitChance!; + secondHitChance = battleInput.Participant2HitChance!; firstHitSeverity = battleInput.Participant1DamageSeverity!; secondHitSeverity = battleInput.Participant2DamageSeverity!; } else { secondHitter = participant1; - firstHitChance = battleInput.Participant2ChanceToHit!; - secondHitChance = battleInput.Participant1ChanceToHit!; + firstHitChance = battleInput.Participant2HitChance!; + secondHitChance = battleInput.Participant1HitChance!; firstHitSeverity = battleInput.Participant2DamageSeverity!; secondHitSeverity = battleInput.Participant1DamageSeverity!; } @@ -119,7 +106,9 @@ public class BattleTool( private static string ResolveCombat(Character firstHitter, Character secondHitter, string firstHitChance, string secondHitChance, string firstHitSeverity, string secondHitSeverity) { - var resultString = string.Empty; + var resultString = $"{firstHitter.Name} described as \"{firstHitter.Description}\" fights " + + $"{secondHitter.Name} described as \"{secondHitter.Description}\"\n"; + resultString += ResolveAttack(firstHitter, secondHitter, firstHitChance, firstHitSeverity); if (secondHitter.CurrentHealth <= 0) @@ -127,7 +116,7 @@ private static string ResolveCombat(Character firstHitter, Character secondHitte return resultString; } - resultString += ResolveAttack(secondHitter, firstHitter, secondHitChance, secondHitSeverity); + resultString += " " + ResolveAttack(secondHitter, firstHitter, secondHitChance, secondHitSeverity); return resultString; } @@ -143,7 +132,7 @@ private static string ResolveAttack(Character damageDealer, Character damageTake { var (minDamage, maxDamage) = DamageRanges[hitSeverity]; var damage = rand.Next(minDamage, maxDamage); - resultString += $"{damageDealer.Name} deals {damage} damage to {damageTaker.Name}."; + resultString += $"{damageDealer.Name} deals {damage} damage to {damageTaker.Name}. "; if (damageTaker.AdjustHealth(-damage)) { if (damageTaker.IsPlayer) diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index 37afe66..0c42af3 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -23,8 +23,6 @@ public GameStateManager(IConfiguration configuration, IPersistenceService persis ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("ApiKeys").GetValue("OpenAI")); ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts") .GetValue("UpdateCampaignFromNarrative")); - ArgumentException.ThrowIfNullOrEmpty(configuration.GetSection("SystemPrompts") - .GetValue("ShouldSummarize")); _provider = new OpenAiProvider(configuration.GetSection("ApiKeys").GetValue("OpenAI")!); _updateCampaignPrompt = configuration.GetSection("SystemPrompts").GetValue("UpdateCampaignFromNarrative")!; From 3fe70245accbc9ccb9c81e55e37a8fd32b926954 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 1 Nov 2024 14:33:30 +0100 Subject: [PATCH 28/51] Adjust battle tool description --- ChatRPG/API/ReActLlmClient.cs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index ba5b5ec..3d89db9 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -116,16 +116,17 @@ private List CreateTools(Campaign campaign) "accumulative for each time they hit an enemy during battle. The damage severity describes how " + "powerful the attack is which is derived from the narrative description of the attacks. " + "If there are no direct description, estimate the impact of an attack based on the character type and " + - "their description. Input to this tool must be in the following RAW JSON format: {{\"name\": " + - "\"\", \"description\": \"\"}, {\"name\": " + - "\"\", \"description\": \"\"}, " + - "\"participant1HitChance\": \"\", \"participant2HitChance\": " + - "\"\", \"participant1DamageSeverity\": " + - "\"\", \"participant2DamageSeverity\": " + - "\"\"} where participant#HitChance specifiers are one " + - "of the following {high, medium, low, impossible} and participant#DamageSeverity is one of " + - "the following {low, medium, high, extraordinary}. Do not use markdown, only raw JSON as input. " + - "The narrative battle is over when each character has had the chance to attack another character at most once."); + "their description. Input to this tool must be in the following RAW JSON format: {\"participant1\": " + + "{\"name\": \"\", \"description\": \"\"}, " + + "\"participant2\": {\"name\": \"\", \"description\": " + + "\"\"}, \"participant1HitChance\": \"\", \"participant2HitChance\": \"\", " + + "\"participant1DamageSeverity\": \"\", " + + "\"participant2DamageSeverity\": \"\"} where participant#HitChance " + + "specifiers are one of the following {high, medium, low, impossible} and participant#DamageSeverity is " + + "one of the following {low, medium, high, extraordinary}. Do not use markdown, only raw JSON as input. " + + "The narrative battle is over when each character has had the chance to attack another character at " + + "most once."); tools.Add(battleTool); return tools; From b136ae4639299386646625b864cd20b39c71ddac Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 4 Nov 2024 12:25:03 +0100 Subject: [PATCH 29/51] Fixed BattleTool (hopefully) and FindCharacter is better at finding the right character and returning none when appropriate --- ChatRPG/API/ReActLlmClient.cs | 16 ++- ChatRPG/API/Tools/BattleTool.cs | 3 +- ChatRPG/API/Tools/ToolUtilities.cs | 12 +- ChatRPG/Data/Models/Character.cs | 3 +- ChatRPG/Pages/CampaignPage.razor.cs | 2 - ChatRPG/Services/GameInputHandler.cs | 178 +-------------------------- ChatRPG/Services/GameStateManager.cs | 15 ++- ChatRPG/appsettings.json | 8 +- 8 files changed, 43 insertions(+), 194 deletions(-) diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 3d89db9..8f9268c 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -13,6 +13,7 @@ public class ReActLlmClient : IReActLlmClient private readonly IConfiguration _configuration; private readonly OpenAiProvider _provider; private readonly string _reActPrompt; + private readonly bool _narratorDebugMode; public ReActLlmClient(IConfiguration configuration) { @@ -21,6 +22,7 @@ public ReActLlmClient(IConfiguration configuration) _configuration = configuration; _reActPrompt = _configuration.GetSection("SystemPrompts").GetValue("ReAct")!; _provider = new OpenAiProvider(_configuration.GetSection("ApiKeys").GetValue("OpenAI")!); + _narratorDebugMode = _configuration.GetValue("NarrativeChainDebug")!; } public async Task GetChatCompletionAsync(Campaign campaign, string actionPrompt, string input) @@ -29,7 +31,9 @@ public async Task GetChatCompletionAsync(Campaign campaign, string actio { Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } }; - var agent = new ReActAgentChain(llm, _reActPrompt, actionPrompt: actionPrompt, campaign.GameSummary); + + var agent = new ReActAgentChain(_narratorDebugMode ? llm.UseConsoleForDebug() : llm, _reActPrompt, + actionPrompt: actionPrompt, campaign.GameSummary); var tools = CreateTools(campaign); foreach (var tool in tools) { @@ -43,13 +47,14 @@ public async Task GetChatCompletionAsync(Campaign campaign, string actio public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt, string input) { - var agentLlm = new Gpt4Model(_provider) + var llm = new Gpt4Model(_provider) { Settings = new OpenAiChatSettings() { UseStreaming = true, Temperature = 0.7 } }; - var eventProcessor = new LlmEventProcessor(agentLlm); - var agent = new ReActAgentChain(agentLlm.UseConsoleForDebug(), _reActPrompt, actionPrompt: actionPrompt, campaign.GameSummary); + var eventProcessor = new LlmEventProcessor(llm); + var agent = new ReActAgentChain(_narratorDebugMode ? llm.UseConsoleForDebug() : llm, _reActPrompt, + actionPrompt: actionPrompt, campaign.GameSummary); var tools = CreateTools(campaign); foreach (var tool in tools) { @@ -105,7 +110,8 @@ private List CreateTools(Campaign campaign) "The battle tool will give each participant a chance to fight the other participant. The tool should " + "also be used when an attack can be mitigated or dodged by the involved participants. It is also " + "possible for either or both participants to miss. A hit chance specifier will help adjust the chance " + - "that a participant gets to retaliate. Example: There are three combatants, the Player's character " + + "that a participant gets to retaliate. Example: There are only two combatants. Call the tool only ONCE " + + "since both characters get an attack. Another example: There are three combatants, the Player's character " + "and two assassins. The battle tool is called first with the Player's character as participant one and " + "one of the assassins as participant two. Chances are high that the player will hit the assassin but " + "assassins must be precise, making it harder to hit, however, they deal high damage if they hit. We " + diff --git a/ChatRPG/API/Tools/BattleTool.cs b/ChatRPG/API/Tools/BattleTool.cs index de18409..29d316c 100644 --- a/ChatRPG/API/Tools/BattleTool.cs +++ b/ChatRPG/API/Tools/BattleTool.cs @@ -93,7 +93,8 @@ public class BattleTool( } return ResolveCombat(firstHitter, secondHitter, firstHitChance, secondHitChance, firstHitSeverity, - secondHitSeverity); + secondHitSeverity) + $" {firstHitter.Name} and {secondHitter.Name}'s battle has " + + "been resolved and this pair can not be used for the battle tool again."; } catch (Exception) { diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 4b204fd..1ebef7e 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -10,6 +10,8 @@ namespace ChatRPG.API.Tools; public class ToolUtilities(IConfiguration configuration) { + private const int IncludedPreviousMessages = 4; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true @@ -30,7 +32,12 @@ public class ToolUtilities(IConfiguration configuration) query.Append($"\n\nThe story up until now: {campaign.GameSummary}"); - query.Append($"\n\nFind the character using the following content: {input}"); + var content = campaign.Messages.TakeLast(IncludedPreviousMessages).Select(m => m.Content); + query.Append("\n\nUse these previous messages as context:"); + foreach (var message in content) + { + query.Append($"\n {message}"); + } query.Append("\n\nHere is the list of all characters present in the story:\n\n{\"characters\": [\n"); @@ -44,6 +51,9 @@ public class ToolUtilities(IConfiguration configuration) query.Append("\n]}"); + query.Append($"\n\nFind the character using the following content: {input}. " + + $"If no character match, do NOT return a character."); + var response = await llm.GenerateAsync(query.ToString()); try diff --git a/ChatRPG/Data/Models/Character.cs b/ChatRPG/Data/Models/Character.cs index e44f4a4..d4adb7e 100644 --- a/ChatRPG/Data/Models/Character.cs +++ b/ChatRPG/Data/Models/Character.cs @@ -6,7 +6,8 @@ private Character() { } - public Character(Campaign campaign, Environment environment, CharacterType type, string name, string description, bool isPlayer) + public Character(Campaign campaign, Environment environment, CharacterType type, string name, string description, + bool isPlayer) { Campaign = campaign; Environment = environment; diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 9da4087..6ee95c9 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -105,8 +105,6 @@ private void InitializeCampaign() } _isWaitingForResponse = true; - OpenAiGptMessage message = new(MessageRole.System, content); - _conversation.Add(message); try { diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index 3cbe4f9..92179ce 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -88,181 +88,7 @@ public async Task HandleInitialPrompt(Campaign campaign, string initialInput) await GetResponseAndUpdateState(campaign, _systemPrompts[SystemPromptType.Initial], initialInput); _logger.LogInformation("Finished processing prompt"); } - - /*private async Task GetRelevantSystemPrompt(Campaign campaign, IList conversation) - { - UserPromptType userPromptType = conversation.Last(m => m.Role.Equals(MessageRole.User)).UserPromptType; - - switch (userPromptType) - { - case UserPromptType.Say: - return _systemPrompts[SystemPromptType.SayAction]; - case UserPromptType.Do: - await DetermineAndPerformHurtOrHeal(campaign, conversation); - return _systemPrompts[SystemPromptType.DoAction]; - case UserPromptType.Attack: - Character? opponent = await DetermineOpponent(campaign, conversation); - if (opponent == null) - { - _logger.LogError("Opponent is unknown!"); - return _systemPrompts[SystemPromptType.DoAction]; - } - - SystemPromptType systemPromptType = DetermineCombatOutcome(); - (int playerDmg, int opponentDmg) = ComputeCombatDamage(systemPromptType, opponent.Type); - ConstructCombatSystemMessage(campaign, playerDmg, opponentDmg, opponent, conversation); - return _systemPrompts[systemPromptType]; - default: - throw new ArgumentOutOfRangeException(); - } - }*/ - - /*private async Task DetermineAndPerformHurtOrHeal(Campaign campaign, ICollection conversation) - { - OpenAiGptMessage lastUserMessage = conversation.Last(m => m.Role.Equals(MessageRole.User)); - string hurtOrHealString = await _llmClient.GetChatCompletionAsync(new List() { lastUserMessage }, - _systemPrompts[SystemPromptType.HurtOrHeal]); - _logger.LogInformation("Hurt or heal response: {hurtOrHealString}", hurtOrHealString); - OpenAiGptMessage hurtOrHealMessage = new(MessageRole.Assistant, hurtOrHealString); - LlmResponse? hurtOrHealResponse = hurtOrHealMessage.TryParseFromJson(); - string hurtOrHealMessageContent = ""; - Random rand = new Random(); - if (hurtOrHealResponse?.Heal == true) - { - int healAmount = rand.Next(PlayerHealMin, PlayerHealMax); - campaign.Player.AdjustHealth(healAmount); - hurtOrHealMessageContent += - $"The player heals {healAmount} health points, setting them at {campaign.Player.CurrentHealth} health points. Mention these numbers in your response."; - } - - if (hurtOrHealResponse?.Hurt == true) - { - int dmgAmount = rand.Next(PlayerDmgMin, PlayerDmgMax); - bool playerDied = campaign.Player.AdjustHealth(-dmgAmount); - hurtOrHealMessageContent += - $"The player hurts themselves for {dmgAmount} damage. The player has {campaign.Player.CurrentHealth} health remaining. Mention these numbers in your response."; - if (playerDied) - { - hurtOrHealMessageContent += "The player has died and their adventure ends."; - } - } - - if (hurtOrHealMessageContent != "") - { - OpenAiGptMessage hurtOrHealSystemMessage = new(MessageRole.System, hurtOrHealMessageContent); - conversation.Add(hurtOrHealSystemMessage); - } - } -*/ - /* private async Task DetermineOpponent(Campaign campaign, IList conversation) - { - string opponentDescriptionString = await _llmClient.GetChatCompletionAsync(conversation, - _systemPrompts[SystemPromptType.CombatOpponentDescription]); - _logger.LogInformation("Opponent description response: {OpponentDescriptionString}", opponentDescriptionString); - OpenAiGptMessage opponentDescriptionMessage = new(MessageRole.Assistant, opponentDescriptionString); - LlmResponse? opponentDescriptionResponse = opponentDescriptionMessage.TryParseFromJson(); - LlmResponseCharacter? resChar = opponentDescriptionResponse?.Characters?.FirstOrDefault(); - if (resChar != null) - { - Environment environment = campaign.Environments.Last(); - Character character = new(campaign, environment, - GameStateManager.ParseToEnum(resChar.Type!, CharacterType.Humanoid), - resChar.Name!, resChar.Description!, false); - campaign.InsertOrUpdateCharacter(character); - } - - string? opponentName = opponentDescriptionResponse?.Opponent?.ToLower(); - return campaign.Characters.LastOrDefault(c => !c.IsPlayer && c.Name.ToLower().Equals(opponentName)); - } - */ - /* private static SystemPromptType DetermineCombatOutcome() - { - Random rand = new Random(); - double playerRoll = rand.NextDouble(); - double opponentRoll = rand.NextDouble(); - - if (playerRoll >= 0.3) - { - return opponentRoll >= 0.6 ? SystemPromptType.CombatHitHit : SystemPromptType.CombatHitMiss; - } - - return opponentRoll >= 0.5 ? SystemPromptType.CombatMissHit : SystemPromptType.CombatMissMiss; - } - - private static (int, int) ComputeCombatDamage(SystemPromptType combatOutcome, CharacterType opponentType) - { - Random rand = new Random(); - int playerDmg = 0; - int opponentDmg = 0; - (int opponentMin, int opponentMax) = CharacterTypeDamageDict[opponentType]; - switch (combatOutcome) - { - case SystemPromptType.CombatHitHit: - playerDmg = rand.Next(PlayerDmgMin, PlayerDmgMax); - opponentDmg = rand.Next(opponentMin, opponentMax); - break; - case SystemPromptType.CombatHitMiss: - playerDmg = rand.Next(PlayerDmgMin, PlayerDmgMax); - break; - case SystemPromptType.CombatMissHit: - opponentDmg = rand.Next(opponentMin, opponentMax); - break; - case SystemPromptType.CombatMissMiss: - break; - } - - return (playerDmg, opponentDmg); - } - */ - /* private void ConstructCombatSystemMessage(Campaign campaign, int playerDmg, int opponentDmg, Character opponent, - IList conversation) - { - string combatMessageContent = ""; - if (playerDmg != 0) - { - if (opponent.AdjustHealth(-playerDmg)) - { - combatMessageContent += - $" With no health points remaining, {opponent.Name} dies and can no longer participate in the narrative."; - } - - combatMessageContent += - $"The player hits with their attack, dealing {playerDmg} damage. The opponent has {opponent.CurrentHealth} health remaining."; - _logger.LogInformation( - "Combat: {PlayerName} hits {OpponentName} for {X} damage. Health: {CurrentHealth}/{MaxHealth}", - campaign.Player.Name, opponent.Name, playerDmg, opponent.CurrentHealth, opponent.MaxHealth); - } - else - { - combatMessageContent += - $"The player misses with their attack, dealing no damage. The opponent has {opponent.CurrentHealth} health remaining."; - } - - if (opponentDmg != 0) - { - bool playerDied = campaign.Player.AdjustHealth(-opponentDmg); - combatMessageContent += - $"The opponent will hit with their next attack, dealing {opponentDmg} damage. The player has {campaign.Player.CurrentHealth} health remaining."; - if (playerDied) - { - combatMessageContent += "The player has died and their adventure ends."; - } - - _logger.LogInformation( - "Combat: {OpponentName} hits {PlayerName} for {X} damage. Health: {CurrentHealth}/{MaxHealth}", - opponent.Name, campaign.Player.Name, opponentDmg, campaign.Player.CurrentHealth, - campaign.Player.MaxHealth); - } - else - { - combatMessageContent += - $"The opponent will miss their next attack, dealing no damage. The player has {campaign.Player.CurrentHealth} health remaining."; - } - - OpenAiGptMessage combatSystemMessage = new(MessageRole.System, combatMessageContent); - conversation.Add(combatSystemMessage); - } - */ + private async Task GetResponseAndUpdateState(Campaign campaign, string actionPrompt, string input) { if (_streamChatCompletions) @@ -291,7 +117,7 @@ private async Task GetResponseAndUpdateState(Campaign campaign, string actionPro private async Task SaveInteraction(Campaign campaign, string input, string response) { await _gameStateManager.StoreMessagesInCampaign(campaign, input, response); - await _gameStateManager.UpdateCampaignFromNarrative(campaign, response); + await _gameStateManager.UpdateCampaignFromNarrative(campaign, input, response); await _gameStateManager.SaveCurrentState(campaign); } diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index 0c42af3..2fc4c37 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -3,6 +3,7 @@ using ChatRPG.API.Tools; using ChatRPG.Data.Models; using LangChain.Chains.StackableChains.Agents.Tools; +using LangChain.Providers; using LangChain.Providers.OpenAI; using LangChain.Providers.OpenAI.Predefined; using static LangChain.Chains.Chain; @@ -16,6 +17,7 @@ public class GameStateManager private readonly OpenAiProvider _provider; private readonly IPersistenceService _persistenceService; private readonly string _updateCampaignPrompt; + private readonly bool _archivistDebugMode; private readonly bool _summarizeMessages; public GameStateManager(IConfiguration configuration, IPersistenceService persistenceService) @@ -26,7 +28,8 @@ public GameStateManager(IConfiguration configuration, IPersistenceService persis _provider = new OpenAiProvider(configuration.GetSection("ApiKeys").GetValue("OpenAI")!); _updateCampaignPrompt = configuration.GetSection("SystemPrompts").GetValue("UpdateCampaignFromNarrative")!; - _summarizeMessages = configuration.GetSection("SystemPrompts").GetValue("ShouldSummarize"); + _archivistDebugMode = configuration.GetValue("ArchivistChainDebug"); + _summarizeMessages = configuration.GetValue("ShouldSummarize"); _persistenceService = persistenceService; } @@ -35,7 +38,7 @@ public async Task SaveCurrentState(Campaign campaign) await _persistenceService.SaveAsync(campaign); } - public async Task UpdateCampaignFromNarrative(Campaign campaign, string narrative) + public async Task UpdateCampaignFromNarrative(Campaign campaign, string input, string narrative) { var llm = new Gpt4Model(_provider) { @@ -67,8 +70,8 @@ public async Task UpdateCampaignFromNarrative(Campaign campaign, string narrativ environments.Append("\n]}"); - var agent = new ReActAgentChain(llm, _updateCampaignPrompt, characters.ToString(), campaign.Player.Name, - environments.ToString(), gameSummary: campaign.GameSummary); + var agent = new ReActAgentChain(_archivistDebugMode ? llm.UseConsoleForDebug() : llm, _updateCampaignPrompt, + characters.ToString(), campaign.Player.Name, environments.ToString(), gameSummary: campaign.GameSummary); var tools = CreateTools(campaign); foreach (var tool in tools) @@ -76,7 +79,9 @@ public async Task UpdateCampaignFromNarrative(Campaign campaign, string narrativ agent.UseTool(tool); } - var chain = Set(narrative, "input") | agent; + var newInformation = $"The player says: {input}\nThe DM says: {narrative}"; + + var chain = Set(newInformation, "input") | agent; await chain.RunAsync("text"); } diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 2d3662b..cb563f8 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -12,17 +12,19 @@ } }, "AllowedHosts": "*", + "NarrativeChainDebug": true, + "ArchivistChainDebug": true, "ShouldSummarize": true, "StreamChatCompletions": true, "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { - "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} Previous tool steps: {history} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input}", + "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. Health value numbers must not be mentioned in the narrative, but should inform the descriptions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input} Previous tool steps: {history}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", - "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. The Player character is {player_character}. Environments in the game: {environments}. Game summary: {summary} Previous tool steps: {history} New narrative message: {input}", + "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. The Player character is {player_character}. Environments in the game: {environments}. Game summary: {summary} Previous tool steps: {history} New narrative messages: {input}", "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", - "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction}. Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object.", + "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction}. Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object. Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The context messages only serve to give a hint of the current scenario.", "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury", "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting", "BattleInstruction": "Find the character that will be involved in a battle or combat" From 04c2d965244f2043d9a33b848a5d79c4649c36d7 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 4 Nov 2024 12:30:19 +0100 Subject: [PATCH 30/51] Fix linter error --- ChatRPG/Services/GameInputHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index 92179ce..e9ea8f1 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -88,7 +88,7 @@ public async Task HandleInitialPrompt(Campaign campaign, string initialInput) await GetResponseAndUpdateState(campaign, _systemPrompts[SystemPromptType.Initial], initialInput); _logger.LogInformation("Finished processing prompt"); } - + private async Task GetResponseAndUpdateState(Campaign campaign, string actionPrompt, string input) { if (_streamChatCompletions) From 40fd3b1a22eb468e014e0598968cdc8452463cbe Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 4 Nov 2024 14:06:48 +0100 Subject: [PATCH 31/51] Added LLM debug modes for narrator and archivist chains --- ChatRPG/API/ReActAgentChain.cs | 3 ++- ChatRPG/API/ReActLlmClient.cs | 3 ++- ChatRPG/Pages/CampaignPage.razor.cs | 6 ++++-- ChatRPG/appsettings.json | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/ChatRPG/API/ReActAgentChain.cs b/ChatRPG/API/ReActAgentChain.cs index 1697db4..034978f 100644 --- a/ChatRPG/API/ReActAgentChain.cs +++ b/ChatRPG/API/ReActAgentChain.cs @@ -215,6 +215,7 @@ await _conversationBufferMemory.ChatHistory.AddMessage(new Message("Thought:", M } } - throw new ReActChainNoFinalAnswerReachedException("The ReAct Chain could not reach a final answer", values); + throw new ReActChainNoFinalAnswerReachedException($"The ReAct Chain could not reach a final answer. " + + $"Values: {values}", values); } } diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 8f9268c..0256458 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -105,7 +105,8 @@ private List CreateTools(Campaign campaign) tools.Add(healCharacterTool); var battleTool = new BattleTool(_configuration, campaign, utils, "battletool", - "Use the battle tool to resolve battle or combat between two participants. If there are more " + + "Use the battle tool to resolve battle or combat between two participants. A participant is " + + "a single character and cannot be a combination of characters. If there are more " + "than two participants, the tool must be used once per attacker to give everyone a chance at fighting. " + "The battle tool will give each participant a chance to fight the other participant. The tool should " + "also be used when an attack can be mitigated or dodged by the involved participants. It is also " + diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 6ee95c9..2a3c27e 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -110,8 +110,9 @@ private void InitializeCampaign() { GameInputHandler?.HandleInitialPrompt(_campaign, content); } - catch (Exception) + catch (Exception e) { + Console.WriteLine($"An error occurred when generating the response: {e.Message}"); _conversation.Add(new OpenAiGptMessage(MessageRole.System, "An error occurred when generating the response \uD83D\uDCA9. " + "Please try again by reloading the campaign.")); @@ -155,8 +156,9 @@ private async Task SendPrompt() await GameInputHandler!.HandleUserPrompt(_campaign, _activeUserPromptType, userInput.Content); _conversation.RemoveAll(m => m.Role.Equals(MessageRole.System)); } - catch (Exception) + catch (Exception e) { + Console.WriteLine($"An error occurred when generating the response: {e.Message}"); _conversation.Add(new OpenAiGptMessage(MessageRole.System, "An error occurred when generating the response \uD83D\uDCA9. Please try again.")); _campaign = await PersistenceService!.LoadFromCampaignIdAsync(_campaign.Id); // Rollback campaign diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index cb563f8..3a1e7d5 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -19,9 +19,9 @@ "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { - "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. Health value numbers must not be mentioned in the narrative, but should inform the descriptions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input} Previous tool steps: {history}", + "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. Use observations to flesh out the narrative. Health value numbers must not be mentioned in the narrative, but should inform the descriptions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input} Previous tool steps: {history}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", - "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. The Player character is {player_character}. Environments in the game: {environments}. Game summary: {summary} Previous tool steps: {history} New narrative messages: {input}", + "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. The Player character is {player_character}. Environments in the game: {environments}. Game summary: {summary} New narrative messages: {input} Previous tool steps: {history}", "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction}. Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object. Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The context messages only serve to give a hint of the current scenario.", From 10dfc6848d8514271e3bd1ffdd62cb3d11209e22 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 4 Nov 2024 14:28:45 +0100 Subject: [PATCH 32/51] Use lower health values for opponent characters so battles do not take ages --- ChatRPG/Data/Models/Character.cs | 9 +++++---- ChatRPG/Data/Models/CharacterType.cs | 7 ++++--- ChatRPG/Services/GameInputHandler.cs | 6 +++--- ChatRPG/Services/GameStateManager.cs | 21 ++++++++++++++++++--- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/ChatRPG/Data/Models/Character.cs b/ChatRPG/Data/Models/Character.cs index d4adb7e..0796342 100644 --- a/ChatRPG/Data/Models/Character.cs +++ b/ChatRPG/Data/Models/Character.cs @@ -17,10 +17,11 @@ public Character(Campaign campaign, Environment environment, CharacterType type, IsPlayer = isPlayer; MaxHealth = type switch { - CharacterType.Humanoid => 50, - CharacterType.SmallCreature => 30, - CharacterType.LargeCreature => 70, - CharacterType.Monster => 90, + CharacterType.Humanoid => 40, + CharacterType.SmallMonster => 15, + CharacterType.MediumMonster => 35, + CharacterType.LargeMonster => 55, + CharacterType.BossMonster => 90, _ => 50 }; if (isPlayer) diff --git a/ChatRPG/Data/Models/CharacterType.cs b/ChatRPG/Data/Models/CharacterType.cs index 463090f..468ca84 100644 --- a/ChatRPG/Data/Models/CharacterType.cs +++ b/ChatRPG/Data/Models/CharacterType.cs @@ -2,8 +2,9 @@ public enum CharacterType { - SmallCreature, + SmallMonster, Humanoid, - LargeCreature, - Monster + MediumMonster, + LargeMonster, + BossMonster } diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index e9ea8f1..66a5907 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -20,9 +20,9 @@ public class GameInputHandler private static readonly Dictionary CharacterTypeDamageDict = new() { { CharacterType.Humanoid, (10, 20) }, - { CharacterType.SmallCreature, (5, 10) }, - { CharacterType.LargeCreature, (15, 25) }, - { CharacterType.Monster, (20, 30) } + { CharacterType.SmallMonster, (5, 10) }, + { CharacterType.LargeMonster, (15, 25) }, + { CharacterType.BossMonster, (20, 30) } }; public GameInputHandler(ILogger logger, IReActLlmClient llmClient, diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index 2fc4c37..099606e 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -89,16 +89,31 @@ private static List CreateTools(Campaign campaign) { var tools = new List(); - var updateCharacterTool = new UpdateCharacterTool(campaign, "updatecharactertool", + var updateCharacterToolDescription = new StringBuilder(); + updateCharacterToolDescription.Append( "This tool must be used to create a new character or update an existing character in the campaign. " + "Example: The narrative text mentions a new character or contains changes to an existing character. " + "Input to this tool must be in the following RAW JSON format: {\"name\": \"\", " + "\"description\": \"\", \"type\": \"\", " + - "\"state\": \"\"}, where type is one of the following: {SmallCreature, Humanoid, " + - "LargeCreature, Monster}, and state is one of the following: {Dead, Unconscious, HeavilyWounded, " + + "\"state\": \"\"}, where type is one of the following: {"); + + var characterTypes = Enum.GetNames(); + for (var i = 0; i < characterTypes.Length; i++) + { + updateCharacterToolDescription.Append($"{characterTypes[i]}"); + if (i < characterTypes.Length - 1) + { + updateCharacterToolDescription.Append(", "); + } + } + + updateCharacterToolDescription.Append( + "}, and state is one of the following: {Dead, Unconscious, HeavilyWounded, " + "LightlyWounded, Healthy}. The description of a character could describe their physical characteristics, " + "personality, what they are known for, or other cool descriptive features. " + "The tool should only be used once per character."); + var updateCharacterTool = + new UpdateCharacterTool(campaign, "updatecharactertool", updateCharacterToolDescription.ToString()); tools.Add(updateCharacterTool); var updateEnvironmentTool = new UpdateEnvironmentTool(campaign, "updateenvironmenttool", From 8f70fdfa2cd5c6f309bbae1859b45c8b275d62fa Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Mon, 4 Nov 2024 14:32:03 +0100 Subject: [PATCH 33/51] Remove unused CharacterTypeDamageDict --- ChatRPG/Services/GameInputHandler.cs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index 66a5907..f93f4b4 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -12,18 +12,6 @@ public class GameInputHandler private readonly GameStateManager _gameStateManager; private readonly bool _streamChatCompletions; private readonly Dictionary _systemPrompts = new(); - private const int PlayerDmgMin = 10; - private const int PlayerDmgMax = 25; - private const int PlayerHealMin = 15; - private const int PlayerHealMax = 30; - - private static readonly Dictionary CharacterTypeDamageDict = new() - { - { CharacterType.Humanoid, (10, 20) }, - { CharacterType.SmallMonster, (5, 10) }, - { CharacterType.LargeMonster, (15, 25) }, - { CharacterType.BossMonster, (20, 30) } - }; public GameInputHandler(ILogger logger, IReActLlmClient llmClient, GameStateManager gameStateManager, IConfiguration configuration) From bc0a0eea29ce797e614ae1fe05bfa6d87d5c8843 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 8 Nov 2024 11:38:07 +0100 Subject: [PATCH 34/51] Added examples to FindCharacter instructions and let BattleTool do harmless fights --- ChatRPG/API/ReActLlmClient.cs | 6 ++- ChatRPG/API/Tools/BattleInput.cs | 19 ++++--- ChatRPG/API/Tools/BattleTool.cs | 1 + .../Pages/Account/Manage/ManageNavPages.cs | 1 - .../Identity/Pages/Account/Register.cshtml | 1 - ChatRPG/Pages/CampaignPage.razor | 50 +++++++++++-------- ChatRPG/Pages/CampaignPage.razor.cs | 1 + ChatRPG/Pages/Index.razor | 2 +- ChatRPG/Pages/Shared/_Layout.cshtml | 4 +- ChatRPG/appsettings.json | 10 ++-- 10 files changed, 55 insertions(+), 40 deletions(-) diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 0256458..8640e65 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -122,6 +122,8 @@ private List CreateTools(Campaign campaign) "already hit once during this narrative, we impose a penalty to their hit chance, which is " + "accumulative for each time they hit an enemy during battle. The damage severity describes how " + "powerful the attack is which is derived from the narrative description of the attacks. " + + "If the participants engage in a friendly sparring fight, does not intend to hurt, or does mock battle, " + + "the damage severity is . " + "If there are no direct description, estimate the impact of an attack based on the character type and " + "their description. Input to this tool must be in the following RAW JSON format: {\"participant1\": " + "{\"name\": \"\", \"description\": \"\"}, " + @@ -131,8 +133,8 @@ private List CreateTools(Campaign campaign) "\"participant1DamageSeverity\": \"\", " + "\"participant2DamageSeverity\": \"\"} where participant#HitChance " + "specifiers are one of the following {high, medium, low, impossible} and participant#DamageSeverity is " + - "one of the following {low, medium, high, extraordinary}. Do not use markdown, only raw JSON as input. " + - "The narrative battle is over when each character has had the chance to attack another character at " + + "one of the following {harmless, low, medium, high, extraordinary}. Do not use markdown, only raw JSON as " + + "input. The narrative battle is over when each character has had the chance to attack another character at " + "most once."); tools.Add(battleTool); diff --git a/ChatRPG/API/Tools/BattleInput.cs b/ChatRPG/API/Tools/BattleInput.cs index a537b04..6d62a4a 100644 --- a/ChatRPG/API/Tools/BattleInput.cs +++ b/ChatRPG/API/Tools/BattleInput.cs @@ -2,8 +2,11 @@ namespace ChatRPG.API.Tools; public class BattleInput { - private static readonly HashSet ValidChancesToHit = ["high", "medium", "low", "impossible"]; - private static readonly HashSet ValidDamageSeverities = ["low", "medium", "high", "extraordinary"]; + private static readonly HashSet ValidChancesToHit = + ["high", "medium", "low", "impossible"]; + + private static readonly HashSet ValidDamageSeverities = + ["harmless", "low", "medium", "high", "extraordinary"]; public CharacterInput? Participant1 { get; set; } public CharacterInput? Participant2 { get; set; } @@ -35,16 +38,20 @@ public bool IsValid(out List validationErrors) } if (Participant1HitChance != null && !ValidChancesToHit.Contains(Participant1HitChance)) - validationErrors.Add("Participant1ChanceToHit must be one of the following: high, medium, low, impossible."); + validationErrors.Add( + "Participant1ChanceToHit must be one of the following: high, medium, low, impossible."); if (Participant2HitChance != null && !ValidChancesToHit.Contains(Participant2HitChance)) - validationErrors.Add("Participant2ChanceToHit must be one of the following: high, medium, low, impossible."); + validationErrors.Add( + "Participant2ChanceToHit must be one of the following: high, medium, low, impossible."); if (Participant1DamageSeverity != null && !ValidDamageSeverities.Contains(Participant1DamageSeverity)) - validationErrors.Add("Participant1DamageSeverity must be one of the following: low, medium, high, extraordinary."); + validationErrors.Add( + "Participant1DamageSeverity must be one of the following: harmless, low, medium, high, extraordinary."); if (Participant2DamageSeverity != null && !ValidDamageSeverities.Contains(Participant2DamageSeverity)) - validationErrors.Add("Participant2DamageSeverity must be one of the following: low, medium, high, extraordinary."); + validationErrors.Add( + "Participant2DamageSeverity must be one of the following: harmless, low, medium, high, extraordinary."); return validationErrors.Count == 0; } diff --git a/ChatRPG/API/Tools/BattleTool.cs b/ChatRPG/API/Tools/BattleTool.cs index 29d316c..f96c289 100644 --- a/ChatRPG/API/Tools/BattleTool.cs +++ b/ChatRPG/API/Tools/BattleTool.cs @@ -27,6 +27,7 @@ public class BattleTool( private static readonly Dictionary DamageRanges = new() { + { "harmless", (0, 1) }, { "low", (5, 10) }, { "medium", (10, 20) }, { "high", (15, 25) }, diff --git a/ChatRPG/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/ChatRPG/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs index 3b66fbb..be24f0f 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs +++ b/ChatRPG/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. #nullable disable -using System; using Microsoft.AspNetCore.Mvc.Rendering; namespace ChatRPG.Areas.Identity.Pages.Account.Manage diff --git a/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml b/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml index ae1b232..52afb0e 100644 --- a/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml +++ b/ChatRPG/Areas/Identity/Pages/Account/Register.cshtml @@ -1,5 +1,4 @@ @page "/Register" -@using Microsoft.AspNetCore.Authentication @model RegisterModel @{ ViewData["Title"] = "Register"; diff --git a/ChatRPG/Pages/CampaignPage.razor b/ChatRPG/Pages/CampaignPage.razor index a9ff88e..c530b51 100644 --- a/ChatRPG/Pages/CampaignPage.razor +++ b/ChatRPG/Pages/CampaignPage.razor @@ -20,31 +20,37 @@
-
-

Location

-
-
-
@_currentLocation?.Name
-
-

@_currentLocation?.Description

+ @if (_currentLocation != null) + { +
+

Location

+
+
+
@_currentLocation?.Name
+
+

@_currentLocation?.Description

+
-
-
-

Characters

-
- @foreach (Character character in _npcList) - { -
-
-
@character.Name
-
-

@character.Description

+ } + @if (_npcList.Any()) + { +
+

Characters

+
+ @foreach (Character character in _npcList) + { +
+
+
@character.Name
+
+

@character.Description

+
-
- } + } +
-
+ }
@@ -70,7 +76,7 @@
- diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 2a3c27e..850b9d1 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -78,6 +78,7 @@ protected override async Task OnInitializedAsync() { InitializeCampaign(); } + StateHasChanged(); } /// diff --git a/ChatRPG/Pages/Index.razor b/ChatRPG/Pages/Index.razor index 61068df..de43338 100644 --- a/ChatRPG/Pages/Index.razor +++ b/ChatRPG/Pages/Index.razor @@ -24,7 +24,7 @@
- © 2023 - ChatRPG + © @DateTime.Now.Year - ChatRPG
diff --git a/ChatRPG/Pages/Shared/_Layout.cshtml b/ChatRPG/Pages/Shared/_Layout.cshtml index e3b6257..1a74a18 100644 --- a/ChatRPG/Pages/Shared/_Layout.cshtml +++ b/ChatRPG/Pages/Shared/_Layout.cshtml @@ -53,7 +53,7 @@
- © 2023 - ChatRPG + © @DateTime.Now.Year - ChatRPG @{ string? foundPrivacy = Url.Page("/Privacy", new { area = "" }); } @@ -87,4 +87,4 @@ @await RenderSectionAsync("Scripts", required: false) - \ No newline at end of file + diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 3a1e7d5..ab6023c 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -21,12 +21,12 @@ "SystemPrompts": { "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. Use observations to flesh out the narrative. Health value numbers must not be mentioned in the narrative, but should inform the descriptions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input} Previous tool steps: {history}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", - "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Assistant must end up with a summary of the characters and environments it has created or updated. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Characters present in the game: {characters}. The Player character is {player_character}. Environments in the game: {environments}. Game summary: {summary} New narrative messages: {input} Previous tool steps: {history}", + "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. If a new character or environment is mentioned that is not yet preset in the current lists, they must be created. Assistant must end up with a summary of the characters and environments it has created or updated. A character can be any entity from a person to a monster. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Game summary: {summary} New narrative messages: {input} Characters present in the game: {characters}. If a character is not in this list, it is not yet tracked in the game and must be created. The Player character is {player_character}. Environments in the game: {environments}. If an environment is not in this list, it is not yet tracked in the game and must be created. Previous tool steps: {history}", "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", - "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction}. Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object. Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The context messages only serve to give a hint of the current scenario.", - "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury", - "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting", - "BattleInstruction": "Find the character that will be involved in a battle or combat" + "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction} Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object. Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The match is still valid if a partial match in name or description is possible. The context messages only serve to give a hint of the current scenario.", + "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury. Example: Find the character corresponding to the following narrative: \"Peter is walking through the forest, passing by Nyanko, the Swift, and suddenly falls into a pit. He is hurt and needs help.\" Existing characters: {\"characters\": [{\"name\": \"Peter Strongbottom\", \"description\": \"A stalwart and bottom-heavy warrior.\"}, {\"name\": \"Nyanko, the Swift\", \"description\": \"A nimble and agile rogue.\"}]}. The character that is hurt is Peter Strongbottom.", + "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting. Example: Find the character corresponding to the following narrative: Kristoffer casts a healing spell on Martin in order to restore his wounds he received from fighting off Arch. Existing characters: {\"characters\": [{\"name\": \"Alpha Werewolf Martin\", \"description\": \"A ferocious and rabid werewolf.\"}, {\"name\": \"Kristoffer, the Submissive\", \"description\": \"The most submissive healer in the kingdom\"},{\"name\": \"Arch\", \"description\": \"A powerful dragon roaming the world for worthy opponents.\"}]}. The character that is healed is Alpha Werewolf Martin.", + "BattleInstruction": "Find the character that will be involved in a battle or combat. Example: Find the character corresponding to the following JSON description: {\"name\": \"Ivan\", \"description\": \"The wielder of Earth, Wind, and Fire.\"}. Existing characters: {\"characters\": [{\"name\": \"Ivan Quintessence, the Magician of Elements\", \"description\": \"A powerful magician that has mastered the elements of Earth, Wind, and Fire\", \"type\": \"Humanoid\"}]. In this case the input character Ivan partially matches the existing character Ivan Quintessence, the Magician of Elements and should therefore be selected." } } From 7268974838783a9f0bee690093be8a963df834cf Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 8 Nov 2024 12:50:34 +0100 Subject: [PATCH 35/51] Switch to Gpt4OmniModel for speed and moved InitializeCampaign to AfterRender --- ChatRPG/API/ReActLlmClient.cs | 4 ++-- ChatRPG/API/Tools/ToolUtilities.cs | 2 +- ChatRPG/Pages/CampaignPage.razor.cs | 14 +++++++------- ChatRPG/Services/GameStateManager.cs | 4 ++-- ChatRPG/appsettings.json | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ChatRPG/API/ReActLlmClient.cs b/ChatRPG/API/ReActLlmClient.cs index 8640e65..7d34c9b 100644 --- a/ChatRPG/API/ReActLlmClient.cs +++ b/ChatRPG/API/ReActLlmClient.cs @@ -27,7 +27,7 @@ public ReActLlmClient(IConfiguration configuration) public async Task GetChatCompletionAsync(Campaign campaign, string actionPrompt, string input) { - var llm = new Gpt4Model(_provider) + var llm = new Gpt4OmniModel(_provider) { Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } }; @@ -47,7 +47,7 @@ public async Task GetChatCompletionAsync(Campaign campaign, string actio public async IAsyncEnumerable GetStreamedChatCompletionAsync(Campaign campaign, string actionPrompt, string input) { - var llm = new Gpt4Model(_provider) + var llm = new Gpt4OmniModel(_provider) { Settings = new OpenAiChatSettings() { UseStreaming = true, Temperature = 0.7 } }; diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 1ebef7e..7fa8955 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -20,7 +20,7 @@ public class ToolUtilities(IConfiguration configuration) public async Task FindCharacter(Campaign campaign, string input, string instruction) { var provider = new OpenAiProvider(configuration.GetSection("ApiKeys")?.GetValue("OpenAI")!); - var llm = new Gpt4Model(provider) + var llm = new Gpt4OmniModel(provider) { Settings = new OpenAiChatSettings() { UseStreaming = false } }; diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 850b9d1..0955836 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -74,11 +74,6 @@ protected override async Task OnInitializedAsync() GameInputHandler!.ChatCompletionReceived += OnChatCompletionReceived; GameInputHandler!.ChatCompletionChunkReceived += OnChatCompletionChunkReceived; - if (_conversation.Count == 0) - { - InitializeCampaign(); - } - StateHasChanged(); } /// @@ -94,10 +89,15 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _detectScrollBarJsScript ??= await JsRuntime!.InvokeAsync("import", "./js/detectScrollBar.js"); await ScrollToElement(BottomId); // scroll down to latest message + + if (_conversation.Count == 0) + { + await InitializeCampaign(); + } } } - private void InitializeCampaign() + private async Task InitializeCampaign() { string content = $"The player is {_campaign!.Player.Name}, described as \"{_campaign.Player.Description}\"."; if (_campaign.StartScenario != null) @@ -109,7 +109,7 @@ private void InitializeCampaign() try { - GameInputHandler?.HandleInitialPrompt(_campaign, content); + await GameInputHandler!.HandleInitialPrompt(_campaign, content); } catch (Exception e) { diff --git a/ChatRPG/Services/GameStateManager.cs b/ChatRPG/Services/GameStateManager.cs index 099606e..b51514c 100644 --- a/ChatRPG/Services/GameStateManager.cs +++ b/ChatRPG/Services/GameStateManager.cs @@ -40,7 +40,7 @@ public async Task SaveCurrentState(Campaign campaign) public async Task UpdateCampaignFromNarrative(Campaign campaign, string input, string narrative) { - var llm = new Gpt4Model(_provider) + var llm = new Gpt4OmniModel(_provider) { Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } }; @@ -142,7 +142,7 @@ public async Task StoreMessagesInCampaign(Campaign campaign, string playerInput, new(assistantOutput, LangChain.Providers.MessageRole.Ai) }; - var summaryLlm = new Gpt4Model(_provider) + var summaryLlm = new Gpt4OmniModel(_provider) { Settings = new OpenAiChatSettings() { UseStreaming = false, Temperature = 0.7 } }; diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index ab6023c..127ceec 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -19,9 +19,9 @@ "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { - "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. Use observations to flesh out the narrative. Health value numbers must not be mentioned in the narrative, but should inform the descriptions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input} Previous tool steps: {history}", + "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. Use observations to flesh out the narrative. Never use markdown! Health value numbers must not be mentioned in the narrative, but should inform the descriptions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input} Previous tool steps: {history}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", - "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. If a new character or environment is mentioned that is not yet preset in the current lists, they must be created. Assistant must end up with a summary of the characters and environments it has created or updated. A character can be any entity from a person to a monster. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Game summary: {summary} New narrative messages: {input} Characters present in the game: {characters}. If a character is not in this list, it is not yet tracked in the game and must be created. The Player character is {player_character}. Environments in the game: {environments}. If an environment is not in this list, it is not yet tracked in the game and must be created. Previous tool steps: {history}", + "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Never use markdown! If a new character or environment is mentioned that is not yet preset in the current lists, they must be created. Assistant must end up with a summary of the characters and environments it has created or updated. A character can be any entity from a person to a monster. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Game summary: {summary} New narrative messages: {input} Characters present in the game: {characters}. If a character is not in this list, it is not yet tracked in the game and must be created. The Player character is {player_character}. Environments in the game: {environments}. If an environment is not in this list, it is not yet tracked in the game and must be created. Previous tool steps: {history}", "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction} Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object. Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The match is still valid if a partial match in name or description is possible. The context messages only serve to give a hint of the current scenario.", From 30d9c97a32b7baa5e40609b533717694716ecc1b Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 8 Nov 2024 14:53:40 +0100 Subject: [PATCH 36/51] FindCharacter and BattleTool fixes --- ChatRPG/API/Tools/BattleTool.cs | 8 ++++---- ChatRPG/API/Tools/ToolUtilities.cs | 2 +- ChatRPG/Pages/CampaignPage.razor.cs | 15 ++++++++------- ChatRPG/appsettings.json | 14 +++++++------- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/ChatRPG/API/Tools/BattleTool.cs b/ChatRPG/API/Tools/BattleTool.cs index f96c289..fe7e19d 100644 --- a/ChatRPG/API/Tools/BattleTool.cs +++ b/ChatRPG/API/Tools/BattleTool.cs @@ -56,11 +56,11 @@ public class BattleTool( } var participant1 = await utilities.FindCharacter(campaign, - $"{{\"name\": {battleInput.Participant1!.Name}, " + - $"\"description\": {battleInput.Participant1.Description}}}", instruction); + $"{{\"name\": \"{battleInput.Participant1!.Name}\", " + + $"\"description\": \"{battleInput.Participant1.Description}\"}}", instruction); var participant2 = await utilities.FindCharacter(campaign, - $"{{\"name\": {battleInput.Participant2!.Name}, " + - $"\"description\": {battleInput.Participant2.Description}}}", instruction); + $"{{\"name\": \"{battleInput.Participant2!.Name}\", " + + $"\"description\": \"{battleInput.Participant2.Description}\"}}", instruction); // Create dummy characters if they do not exist and pray that the archive chain will update them participant1 ??= new Character(campaign, campaign.Player.Environment, CharacterType.Humanoid, diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 7fa8955..84442a0 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -54,7 +54,7 @@ public class ToolUtilities(IConfiguration configuration) query.Append($"\n\nFind the character using the following content: {input}. " + $"If no character match, do NOT return a character."); - var response = await llm.GenerateAsync(query.ToString()); + var response = await llm.UseConsoleForDebug().GenerateAsync(query.ToString()); try { diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 0955836..15a8368 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -26,6 +26,7 @@ public partial class CampaignPage private Character? _mainCharacter; private UserPromptType _activeUserPromptType = UserPromptType.Do; private string _userInputPlaceholder = InputPlaceholder[UserPromptType.Do]; + private bool _pageInitialized = false; private static readonly Dictionary InputPlaceholder = new() { @@ -74,6 +75,7 @@ protected override async Task OnInitializedAsync() GameInputHandler!.ChatCompletionReceived += OnChatCompletionReceived; GameInputHandler!.ChatCompletionChunkReceived += OnChatCompletionChunkReceived; + _pageInitialized = true; } /// @@ -89,11 +91,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _detectScrollBarJsScript ??= await JsRuntime!.InvokeAsync("import", "./js/detectScrollBar.js"); await ScrollToElement(BottomId); // scroll down to latest message + } - if (_conversation.Count == 0) - { - await InitializeCampaign(); - } + if (_pageInitialized && _conversation.Count == 0) + { + await InitializeCampaign(); } } @@ -249,9 +251,8 @@ private void OnPromptTypeChange(UserPromptType type) /// private void UpdateStatsUi() { - _npcList = _campaign!.Characters.Where(c => !c.IsPlayer).ToList(); - _npcList.Reverse(); // Show the most newly encountered npc first - _currentLocation = _campaign!.Environments.LastOrDefault(); + _npcList = _campaign!.Characters.Where(c => !c.IsPlayer).OrderByDescending(c => c.Id).ToList(); + _currentLocation = _campaign!.Player.Environment; _mainCharacter = _campaign!.Player; StateHasChanged(); } diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 127ceec..7fb8f17 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -12,21 +12,21 @@ } }, "AllowedHosts": "*", - "NarrativeChainDebug": true, - "ArchivistChainDebug": true, + "NarrativeChainDebug": false, + "ArchivistChainDebug": false, "ShouldSummarize": true, "StreamChatCompletions": true, "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { - "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. Use observations to flesh out the narrative. Never use markdown! Health value numbers must not be mentioned in the narrative, but should inform the descriptions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input} Previous tool steps: {history}", + "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. Use observations to flesh out the narrative. Make sure to always provide immersive and engaging leads in the narrative. Give the player clues, options for interaction, and make sure to keep the story going forward. Health value numbers must not be mentioned in the narrative, but should inform the descriptions. Never use markdown! TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: Thought: Do I need to use a tool? No Final Answer: [your response here] Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input} Previous tool steps: {history}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", - "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Never use markdown! If a new character or environment is mentioned that is not yet preset in the current lists, they must be created. Assistant must end up with a summary of the characters and environments it has created or updated. A character can be any entity from a person to a monster. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: ``` Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action ``` When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: ``` Thought: Do I need to use a tool? No Final Answer: [your response here] ``` Always add [END] after final answer Begin! Game summary: {summary} New narrative messages: {input} Characters present in the game: {characters}. If a character is not in this list, it is not yet tracked in the game and must be created. The Player character is {player_character}. Environments in the game: {environments}. If an environment is not in this list, it is not yet tracked in the game and must be created. Previous tool steps: {history}", + "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Never use markdown! If a new character or environment is mentioned that is not yet preset in the current lists, they must be created. Assistant must end up with a summary of the characters and environments it has created or updated. A character can be any entity from a person to a monster. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: Thought: Do I need to use a tool? No Final Answer: [your response here] Always add [END] after final answer Begin! Game summary: {summary} New narrative messages: {input} Characters present in the game: {characters}. If a character is not in this list, it is not yet tracked in the game and must be created. The Player character is {player_character}. Environments in the game: {environments}. If an environment is not in this list, it is not yet tracked in the game and must be created. Previous tool steps: {history}", "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", - "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction} Once you have determined the correct character, you must return only its name, description, and type which you received in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object. Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The match is still valid if a partial match in name or description is possible. The context messages only serve to give a hint of the current scenario.", - "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury. Example: Find the character corresponding to the following narrative: \"Peter is walking through the forest, passing by Nyanko, the Swift, and suddenly falls into a pit. He is hurt and needs help.\" Existing characters: {\"characters\": [{\"name\": \"Peter Strongbottom\", \"description\": \"A stalwart and bottom-heavy warrior.\"}, {\"name\": \"Nyanko, the Swift\", \"description\": \"A nimble and agile rogue.\"}]}. The character that is hurt is Peter Strongbottom.", - "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting. Example: Find the character corresponding to the following narrative: Kristoffer casts a healing spell on Martin in order to restore his wounds he received from fighting off Arch. Existing characters: {\"characters\": [{\"name\": \"Alpha Werewolf Martin\", \"description\": \"A ferocious and rabid werewolf.\"}, {\"name\": \"Kristoffer, the Submissive\", \"description\": \"The most submissive healer in the kingdom\"},{\"name\": \"Arch\", \"description\": \"A powerful dragon roaming the world for worthy opponents.\"}]}. The character that is healed is Alpha Werewolf Martin.", + "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction} Once you have determined the correct character, you must return only its exact name, description, and type which you have found in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object as such \"{}\", without markdown. Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The match is still valid if a partial match in name or description is possible. Character names and descriptions given as context can be shortened, so partial matches must be made in such cases. The context messages only serve to give a hint of the current scenario. Never use markdown in the answer!", + "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury. Example: Find the character corresponding to the following content: \"As Peter, I wield my powered-up energy sword causing the flesh from my fingers to splinter. I pass by Nyanko, the Swift, as I head forwards towards the Ancient Tower. \" Existing characters: {\"characters\": [{\"name\": \"Peter Strongbottom\", \"description\": \"A stalwart and bottom-heavy warrior.\"}, {\"name\": \"Nyanko, the Swift\", \"description\": \"A nimble and agile rogue.\"}]}. The character that is hurt is Peter Strongbottom.", + "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting. Example: Find the character corresponding to the following content: Kristoffer casts a healing spell on Martin in order to restore his wounds he received from fighting off Arch. Existing characters: {\"characters\": [{\"name\": \"Alpha Werewolf Martin\", \"description\": \"A ferocious and rabid werewolf.\"}, {\"name\": \"Kristoffer, the Submissive\", \"description\": \"The most submissive healer in the kingdom\"},{\"name\": \"Arch\", \"description\": \"A powerful dragon roaming the world for worthy opponents.\"}]}. The character that is healed is Alpha Werewolf Martin.", "BattleInstruction": "Find the character that will be involved in a battle or combat. Example: Find the character corresponding to the following JSON description: {\"name\": \"Ivan\", \"description\": \"The wielder of Earth, Wind, and Fire.\"}. Existing characters: {\"characters\": [{\"name\": \"Ivan Quintessence, the Magician of Elements\", \"description\": \"A powerful magician that has mastered the elements of Earth, Wind, and Fire\", \"type\": \"Humanoid\"}]. In this case the input character Ivan partially matches the existing character Ivan Quintessence, the Magician of Elements and should therefore be selected." } } From aeff689ced408e04cde6b50fd83272f591c17578 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 8 Nov 2024 15:01:45 +0100 Subject: [PATCH 37/51] Move scrolling js to every time page is rendered --- ChatRPG/API/Tools/ToolUtilities.cs | 2 +- ChatRPG/Pages/CampaignPage.razor.cs | 3 ++- ChatRPG/appsettings.json | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 84442a0..7fa8955 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -54,7 +54,7 @@ public class ToolUtilities(IConfiguration configuration) query.Append($"\n\nFind the character using the following content: {input}. " + $"If no character match, do NOT return a character."); - var response = await llm.UseConsoleForDebug().GenerateAsync(query.ToString()); + var response = await llm.GenerateAsync(query.ToString()); try { diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 15a8368..86dfeb5 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -90,13 +90,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _scrollJsScript ??= await JsRuntime!.InvokeAsync("import", "./js/scroll.js"); _detectScrollBarJsScript ??= await JsRuntime!.InvokeAsync("import", "./js/detectScrollBar.js"); - await ScrollToElement(BottomId); // scroll down to latest message } if (_pageInitialized && _conversation.Count == 0) { await InitializeCampaign(); } + + await ScrollToElement(BottomId); // scroll down to latest message } private async Task InitializeCampaign() diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 7fb8f17..4e127e9 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -12,9 +12,9 @@ } }, "AllowedHosts": "*", - "NarrativeChainDebug": false, - "ArchivistChainDebug": false, - "ShouldSummarize": true, + "NarrativeChainDebug": true, + "ArchivistChainDebug": true, + "ShouldSummarize": false, "StreamChatCompletions": true, "UseMocks": false, "ShouldSendEmails": true, From 632303297e8316e42432651409b473a782ed97e9 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Fri, 8 Nov 2024 15:04:45 +0100 Subject: [PATCH 38/51] Set ShouldSummarize to true again, or we go broke --- ChatRPG/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 4e127e9..80e8b2e 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -14,7 +14,7 @@ "AllowedHosts": "*", "NarrativeChainDebug": true, "ArchivistChainDebug": true, - "ShouldSummarize": false, + "ShouldSummarize": true, "StreamChatCompletions": true, "UseMocks": false, "ShouldSendEmails": true, From a5b3989c8e4852ca68044d41936cb67597b8c96e Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Mon, 11 Nov 2024 14:25:39 +0100 Subject: [PATCH 39/51] Added markdown removal --- ChatRPG/API/ResponseCleaner.cs | 14 ++++++++++++++ ChatRPG/API/Tools/BattleTool.cs | 4 ++-- ChatRPG/API/Tools/HealCharacterTool.cs | 4 ++-- ChatRPG/API/Tools/ToolUtilities.cs | 4 ++-- ChatRPG/API/Tools/UpdateCharacterTool.cs | 4 ++-- ChatRPG/API/Tools/UpdateEnvironmentTool.cs | 4 ++-- ChatRPG/API/Tools/WoundCharacterTool.cs | 4 ++-- ChatRPG/appsettings.json | 6 +++--- 8 files changed, 29 insertions(+), 15 deletions(-) create mode 100644 ChatRPG/API/ResponseCleaner.cs diff --git a/ChatRPG/API/ResponseCleaner.cs b/ChatRPG/API/ResponseCleaner.cs new file mode 100644 index 0000000..1a34f48 --- /dev/null +++ b/ChatRPG/API/ResponseCleaner.cs @@ -0,0 +1,14 @@ +namespace ChatRPG.API; + +public class ResponseCleaner +{ + public static string RemoveMarkdown(string text) + { + if (text.StartsWith("```json") && text.EndsWith("```")) + { + text = text.Replace("```json", ""); + text = text.Replace("```", ""); + } + return text; + } +} \ No newline at end of file diff --git a/ChatRPG/API/Tools/BattleTool.cs b/ChatRPG/API/Tools/BattleTool.cs index fe7e19d..b2fde81 100644 --- a/ChatRPG/API/Tools/BattleTool.cs +++ b/ChatRPG/API/Tools/BattleTool.cs @@ -38,7 +38,7 @@ public class BattleTool( { try { - var battleInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var battleInput = JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("BattleInstruction")!; @@ -101,7 +101,7 @@ public class BattleTool( { return "Could not execute the battle. Tool input format was invalid. " + - "Please provide the input in valid JSON without markdown."; + "Please provide the input in valid JSON."; } } diff --git a/ChatRPG/API/Tools/HealCharacterTool.cs b/ChatRPG/API/Tools/HealCharacterTool.cs index dabb893..37085a3 100644 --- a/ChatRPG/API/Tools/HealCharacterTool.cs +++ b/ChatRPG/API/Tools/HealCharacterTool.cs @@ -28,7 +28,7 @@ public class HealCharacterTool( { try { - var healInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var healInput = JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("HealCharacterInstruction")!; @@ -54,7 +54,7 @@ public class HealCharacterTool( catch (Exception) { return "Could not determine the character to heal. Tool input format was invalid. " + - "Please provide a valid character name, description, and magnitude level in valid JSON without markdown."; + "Please provide a valid character name, description, and magnitude level in valid JSON."; } } } diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 7fa8955..3d51f6e 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -50,7 +50,7 @@ public class ToolUtilities(IConfiguration configuration) query.Length--; // Remove last comma query.Append("\n]}"); - + query.Append($"\n\nFind the character using the following content: {input}. " + $"If no character match, do NOT return a character."); @@ -59,7 +59,7 @@ public class ToolUtilities(IConfiguration configuration) try { var llmResponseCharacter = - JsonSerializer.Deserialize(response.ToString(), JsonOptions); + JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(response.ToString()), JsonOptions); if (llmResponseCharacter is null) return null; diff --git a/ChatRPG/API/Tools/UpdateCharacterTool.cs b/ChatRPG/API/Tools/UpdateCharacterTool.cs index 79089db..dc4349a 100644 --- a/ChatRPG/API/Tools/UpdateCharacterTool.cs +++ b/ChatRPG/API/Tools/UpdateCharacterTool.cs @@ -19,7 +19,7 @@ public class UpdateCharacterTool( { try { - var updateCharacterInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var updateCharacterInput = JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); if (updateCharacterInput.Name.IsNullOrEmpty()) { @@ -74,7 +74,7 @@ public class UpdateCharacterTool( catch (JsonException) { return Task.FromResult("Could not determine the character to update. Tool input format was invalid. " + - "Please provide a valid character name, description, type, and state in valid JSON without markdown."); + "Please provide a valid character name, description, type, and state in valid JSON."); } } diff --git a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs index 10a0145..568225e 100644 --- a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs +++ b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs @@ -20,7 +20,7 @@ public class UpdateEnvironmentTool( { try { - var updateEnvironmentInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var updateEnvironmentInput = JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); if (updateEnvironmentInput.Name.IsNullOrEmpty()) @@ -73,7 +73,7 @@ public class UpdateEnvironmentTool( { return Task.FromResult("Could not determine the environment to update. Tool input format was invalid. " + "Please provide a valid environment name, description, and determine if the " + - "player character is present in the environment in valid JSON without markdown."); + "player character is present in the environment in valid JSON."); } } } diff --git a/ChatRPG/API/Tools/WoundCharacterTool.cs b/ChatRPG/API/Tools/WoundCharacterTool.cs index e37baac..95caf7f 100644 --- a/ChatRPG/API/Tools/WoundCharacterTool.cs +++ b/ChatRPG/API/Tools/WoundCharacterTool.cs @@ -29,7 +29,7 @@ public class WoundCharacterTool( { try { - var woundInput = JsonSerializer.Deserialize(input, JsonOptions) ?? + var woundInput = JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("WoundCharacterInstruction")!; @@ -70,7 +70,7 @@ public class WoundCharacterTool( catch (Exception) { return "Could not determine the character to wound. Tool input format was invalid. " + - "Please provide a valid character name, description, and severity level in valid JSON without markdown."; + "Please provide a valid character name, description, and severity level in valid JSON."; } } } diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 80e8b2e..a0302c8 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -19,12 +19,12 @@ "UseMocks": false, "ShouldSendEmails": true, "SystemPrompts": { - "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. Use observations to flesh out the narrative. Make sure to always provide immersive and engaging leads in the narrative. Give the player clues, options for interaction, and make sure to keep the story going forward. Health value numbers must not be mentioned in the narrative, but should inform the descriptions. Never use markdown! TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: Thought: Do I need to use a tool? No Final Answer: [your response here] Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input} Previous tool steps: {history}", + "ReAct": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG. Assistant is designed to be able to assist with a wide range of tasks, from directing the narrative and controlling non-player characters. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide an engaging and immersive narrative in response to a wide range of player actions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the narrative and provide explanations and descriptions on a wide range of RPG concepts. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable narratives as an expert game master in a RPG. Assistant must end up with a narrative answer once it has resolved the players actions. Use observations to flesh out the narrative. Make sure to always provide immersive and engaging leads in the narrative. Give the player clues, options for interaction, and make sure to keep the story going forward. Health value numbers must not be mentioned in the narrative, but should inform the descriptions. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action When you have a response to say to the Player, you have resolved the Player's action, or if you do not need to use a tool, you MUST use the format: Thought: Do I need to use a tool? No Final Answer: [your response here] Always add [END] after final answer Begin! Answer length: Concise and only a few, engaging sentences. Game summary: {summary} It is important that Assistant take the following into account when constructing the narrative: {action} New input: {input} Previous tool steps: {history}", "Initial": "The player's adventure has just begun. You must provide an in-depth introduction to the campaign. Address the player in the second person.", - "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. Never use markdown! If a new character or environment is mentioned that is not yet preset in the current lists, they must be created. Assistant must end up with a summary of the characters and environments it has created or updated. A character can be any entity from a person to a monster. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: Thought: Do I need to use a tool? No Final Answer: [your response here] Always add [END] after final answer Begin! Game summary: {summary} New narrative messages: {input} Characters present in the game: {characters}. If a character is not in this list, it is not yet tracked in the game and must be created. The Player character is {player_character}. Environments in the game: {environments}. If an environment is not in this list, it is not yet tracked in the game and must be created. Previous tool steps: {history}", + "UpdateCampaignFromNarrative": "Assistant is a large language model trained by OpenAI. Assistant is an expert game master in a single-player RPG and a skilled archivist who is able to track changes in a developing world. Assistant is designed to be able to assist with a wide range of tasks, from maintaining the game state and updating the characters and environments in the game. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand. Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to make important game state decision about events that need to be archived. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in reasoning about the game state and provide explanations and arguments for how to keep the game state up to date. Overall, Assistant is a powerful tool that can help with a wide range of tasks and provide valuable reasoning for what and how to archive game states. If a new character or environment is mentioned that is not yet preset in the current lists, they must be created. Assistant must end up with a summary of the characters and environments it has created or updated. A character can be any entity from a person to a monster. TOOLS: ------ Assistant has access to the following tools: {tools} To use a tool, please use the following format: Thought: Do I need to use a tool? Yes Action: the action to take, should be one of [{tool_names}] Action Input: the input to the action Observation:\n the result of the action When you have a response after archiving the necessary game state elements, no archiving was necessary, or if you do not need to use a tool, you MUST use the format: Thought: Do I need to use a tool? No Final Answer: [your response here] Always add [END] after final answer Begin! Game summary: {summary} New narrative messages: {input} Characters present in the game: {characters}. If a character is not in this list, it is not yet tracked in the game and must be created. The Player character is {player_character}. Environments in the game: {environments}. If an environment is not in this list, it is not yet tracked in the game and must be created. Previous tool steps: {history}", "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", - "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction} Once you have determined the correct character, you must return only its exact name, description, and type which you have found in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object as such \"{}\", without markdown. Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The match is still valid if a partial match in name or description is possible. Character names and descriptions given as context can be shortened, so partial matches must be made in such cases. The context messages only serve to give a hint of the current scenario. Never use markdown in the answer!", + "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction} Once you have determined the correct character, you must return only its exact name, description, and type which you have found in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object as such \"{}\". Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The match is still valid if a partial match in name or description is possible. Character names and descriptions given as context can be shortened, so partial matches must be made in such cases. The context messages only serve to give a hint of the current scenario.", "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury. Example: Find the character corresponding to the following content: \"As Peter, I wield my powered-up energy sword causing the flesh from my fingers to splinter. I pass by Nyanko, the Swift, as I head forwards towards the Ancient Tower. \" Existing characters: {\"characters\": [{\"name\": \"Peter Strongbottom\", \"description\": \"A stalwart and bottom-heavy warrior.\"}, {\"name\": \"Nyanko, the Swift\", \"description\": \"A nimble and agile rogue.\"}]}. The character that is hurt is Peter Strongbottom.", "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting. Example: Find the character corresponding to the following content: Kristoffer casts a healing spell on Martin in order to restore his wounds he received from fighting off Arch. Existing characters: {\"characters\": [{\"name\": \"Alpha Werewolf Martin\", \"description\": \"A ferocious and rabid werewolf.\"}, {\"name\": \"Kristoffer, the Submissive\", \"description\": \"The most submissive healer in the kingdom\"},{\"name\": \"Arch\", \"description\": \"A powerful dragon roaming the world for worthy opponents.\"}]}. The character that is healed is Alpha Werewolf Martin.", "BattleInstruction": "Find the character that will be involved in a battle or combat. Example: Find the character corresponding to the following JSON description: {\"name\": \"Ivan\", \"description\": \"The wielder of Earth, Wind, and Fire.\"}. Existing characters: {\"characters\": [{\"name\": \"Ivan Quintessence, the Magician of Elements\", \"description\": \"A powerful magician that has mastered the elements of Earth, Wind, and Fire\", \"type\": \"Humanoid\"}]. In this case the input character Ivan partially matches the existing character Ivan Quintessence, the Magician of Elements and should therefore be selected." From ad3b7a08561d607d7bc98c33552235cdfe53d894 Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Mon, 11 Nov 2024 15:32:56 +0100 Subject: [PATCH 40/51] Improved find character so it knows who the player is and who first-person refers to --- ChatRPG/API/Tools/ToolUtilities.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 3d51f6e..c82d9bc 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -51,8 +51,9 @@ public class ToolUtilities(IConfiguration configuration) query.Append("\n]}"); - query.Append($"\n\nFind the character using the following content: {input}. " + - $"If no character match, do NOT return a character."); + query.Append($"\n\nThe player is {campaign.Player.Name}. First-person pronouns refer to them."); + + query.Append($"\n\nFind the character using the following content: {input}."); var response = await llm.GenerateAsync(query.ToString()); From 7b7d9ea00a241852e576c8392196e592b7410989 Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Mon, 11 Nov 2024 15:34:21 +0100 Subject: [PATCH 41/51] Linter --- ChatRPG/API/Tools/ToolUtilities.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index c82d9bc..7d27413 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -50,9 +50,9 @@ public class ToolUtilities(IConfiguration configuration) query.Length--; // Remove last comma query.Append("\n]}"); - + query.Append($"\n\nThe player is {campaign.Player.Name}. First-person pronouns refer to them."); - + query.Append($"\n\nFind the character using the following content: {input}."); var response = await llm.GenerateAsync(query.ToString()); @@ -60,7 +60,8 @@ public class ToolUtilities(IConfiguration configuration) try { var llmResponseCharacter = - JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(response.ToString()), JsonOptions); + JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(response.ToString()), + JsonOptions); if (llmResponseCharacter is null) return null; @@ -85,4 +86,4 @@ public class ToolUtilities(IConfiguration configuration) return null; // Format was unexpected } } -} +} \ No newline at end of file From 4d383f209164927f5188f8d7e133fb93ac31f5f8 Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Mon, 11 Nov 2024 15:40:16 +0100 Subject: [PATCH 42/51] Fixed bug where campaign page keeps scrolling to bottom --- ChatRPG/Pages/CampaignPage.razor.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 86dfeb5..5ae618e 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -90,6 +90,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _scrollJsScript ??= await JsRuntime!.InvokeAsync("import", "./js/scroll.js"); _detectScrollBarJsScript ??= await JsRuntime!.InvokeAsync("import", "./js/detectScrollBar.js"); + await ScrollToElement(BottomId); // scroll down to latest message } if (_pageInitialized && _conversation.Count == 0) @@ -97,7 +98,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await InitializeCampaign(); } - await ScrollToElement(BottomId); // scroll down to latest message + } private async Task InitializeCampaign() From cbc6d0f68e3bae44f7e3875c686f5afe466dae7e Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Mon, 11 Nov 2024 15:40:46 +0100 Subject: [PATCH 43/51] Linter --- ChatRPG/Pages/CampaignPage.razor.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 5ae618e..3667996 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -97,8 +97,6 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { await InitializeCampaign(); } - - } private async Task InitializeCampaign() @@ -258,4 +256,4 @@ private void UpdateStatsUi() _mainCharacter = _campaign!.Player; StateHasChanged(); } -} +} \ No newline at end of file From 152a59cd2b0947205988b7eff8e3044ed1f15391 Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Mon, 11 Nov 2024 15:49:35 +0100 Subject: [PATCH 44/51] Moved the RemoveMarkdown function to ToolUtils --- ChatRPG/API/ResponseCleaner.cs | 14 -------------- ChatRPG/API/Tools/BattleTool.cs | 2 +- ChatRPG/API/Tools/HealCharacterTool.cs | 2 +- ChatRPG/API/Tools/ToolUtilities.cs | 12 +++++++++++- ChatRPG/API/Tools/UpdateCharacterTool.cs | 2 +- ChatRPG/API/Tools/UpdateEnvironmentTool.cs | 2 +- ChatRPG/API/Tools/WoundCharacterTool.cs | 2 +- 7 files changed, 16 insertions(+), 20 deletions(-) delete mode 100644 ChatRPG/API/ResponseCleaner.cs diff --git a/ChatRPG/API/ResponseCleaner.cs b/ChatRPG/API/ResponseCleaner.cs deleted file mode 100644 index 1a34f48..0000000 --- a/ChatRPG/API/ResponseCleaner.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ChatRPG.API; - -public class ResponseCleaner -{ - public static string RemoveMarkdown(string text) - { - if (text.StartsWith("```json") && text.EndsWith("```")) - { - text = text.Replace("```json", ""); - text = text.Replace("```", ""); - } - return text; - } -} \ No newline at end of file diff --git a/ChatRPG/API/Tools/BattleTool.cs b/ChatRPG/API/Tools/BattleTool.cs index b2fde81..f643c8d 100644 --- a/ChatRPG/API/Tools/BattleTool.cs +++ b/ChatRPG/API/Tools/BattleTool.cs @@ -38,7 +38,7 @@ public class BattleTool( { try { - var battleInput = JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(input), JsonOptions) ?? + var battleInput = JsonSerializer.Deserialize(ToolUtilities.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("BattleInstruction")!; diff --git a/ChatRPG/API/Tools/HealCharacterTool.cs b/ChatRPG/API/Tools/HealCharacterTool.cs index 37085a3..881a20b 100644 --- a/ChatRPG/API/Tools/HealCharacterTool.cs +++ b/ChatRPG/API/Tools/HealCharacterTool.cs @@ -28,7 +28,7 @@ public class HealCharacterTool( { try { - var healInput = JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(input), JsonOptions) ?? + var healInput = JsonSerializer.Deserialize(ToolUtilities.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("HealCharacterInstruction")!; diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 7d27413..2d6e1c4 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -60,7 +60,7 @@ public class ToolUtilities(IConfiguration configuration) try { var llmResponseCharacter = - JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(response.ToString()), + JsonSerializer.Deserialize(RemoveMarkdown(response.ToString()), JsonOptions); if (llmResponseCharacter is null) return null; @@ -86,4 +86,14 @@ public class ToolUtilities(IConfiguration configuration) return null; // Format was unexpected } } + + public static string RemoveMarkdown(string text) + { + if (text.StartsWith("```json") && text.EndsWith("```")) + { + text = text.Replace("```json", ""); + text = text.Replace("```", ""); + } + return text; + } } \ No newline at end of file diff --git a/ChatRPG/API/Tools/UpdateCharacterTool.cs b/ChatRPG/API/Tools/UpdateCharacterTool.cs index dc4349a..883d9ef 100644 --- a/ChatRPG/API/Tools/UpdateCharacterTool.cs +++ b/ChatRPG/API/Tools/UpdateCharacterTool.cs @@ -19,7 +19,7 @@ public class UpdateCharacterTool( { try { - var updateCharacterInput = JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(input), JsonOptions) ?? + var updateCharacterInput = JsonSerializer.Deserialize(ToolUtilities.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); if (updateCharacterInput.Name.IsNullOrEmpty()) { diff --git a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs index 568225e..8b3174f 100644 --- a/ChatRPG/API/Tools/UpdateEnvironmentTool.cs +++ b/ChatRPG/API/Tools/UpdateEnvironmentTool.cs @@ -20,7 +20,7 @@ public class UpdateEnvironmentTool( { try { - var updateEnvironmentInput = JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(input), JsonOptions) ?? + var updateEnvironmentInput = JsonSerializer.Deserialize(ToolUtilities.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); if (updateEnvironmentInput.Name.IsNullOrEmpty()) diff --git a/ChatRPG/API/Tools/WoundCharacterTool.cs b/ChatRPG/API/Tools/WoundCharacterTool.cs index 95caf7f..4fdc44f 100644 --- a/ChatRPG/API/Tools/WoundCharacterTool.cs +++ b/ChatRPG/API/Tools/WoundCharacterTool.cs @@ -29,7 +29,7 @@ public class WoundCharacterTool( { try { - var woundInput = JsonSerializer.Deserialize(ResponseCleaner.RemoveMarkdown(input), JsonOptions) ?? + var woundInput = JsonSerializer.Deserialize(ToolUtilities.RemoveMarkdown(input), JsonOptions) ?? throw new JsonException("Failed to deserialize"); var instruction = configuration.GetSection("SystemPrompts").GetValue("WoundCharacterInstruction")!; From 0508bae707133100a417d3178241194ece5389d4 Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Mon, 11 Nov 2024 15:54:25 +0100 Subject: [PATCH 45/51] Linter --- ChatRPG/API/Tools/ToolUtilities.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ChatRPG/API/Tools/ToolUtilities.cs b/ChatRPG/API/Tools/ToolUtilities.cs index 2d6e1c4..056d7e2 100644 --- a/ChatRPG/API/Tools/ToolUtilities.cs +++ b/ChatRPG/API/Tools/ToolUtilities.cs @@ -86,7 +86,7 @@ public class ToolUtilities(IConfiguration configuration) return null; // Format was unexpected } } - + public static string RemoveMarkdown(string text) { if (text.StartsWith("```json") && text.EndsWith("```")) @@ -94,6 +94,7 @@ public static string RemoveMarkdown(string text) text = text.Replace("```json", ""); text = text.Replace("```", ""); } + return text; } } \ No newline at end of file From 6f420463efeef5cbe3a3bcc5a611c9c68da38cac Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Mon, 11 Nov 2024 16:38:40 +0100 Subject: [PATCH 46/51] Added examples to Wound and Heal instructions --- ChatRPG/appsettings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index a0302c8..91b4b68 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -25,8 +25,8 @@ "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction} Once you have determined the correct character, you must return only its exact name, description, and type which you have found in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object as such \"{}\". Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The match is still valid if a partial match in name or description is possible. Character names and descriptions given as context can be shortened, so partial matches must be made in such cases. The context messages only serve to give a hint of the current scenario.", - "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury. Example: Find the character corresponding to the following content: \"As Peter, I wield my powered-up energy sword causing the flesh from my fingers to splinter. I pass by Nyanko, the Swift, as I head forwards towards the Ancient Tower. \" Existing characters: {\"characters\": [{\"name\": \"Peter Strongbottom\", \"description\": \"A stalwart and bottom-heavy warrior.\"}, {\"name\": \"Nyanko, the Swift\", \"description\": \"A nimble and agile rogue.\"}]}. The character that is hurt is Peter Strongbottom.", - "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting. Example: Find the character corresponding to the following content: Kristoffer casts a healing spell on Martin in order to restore his wounds he received from fighting off Arch. Existing characters: {\"characters\": [{\"name\": \"Alpha Werewolf Martin\", \"description\": \"A ferocious and rabid werewolf.\"}, {\"name\": \"Kristoffer, the Submissive\", \"description\": \"The most submissive healer in the kingdom\"},{\"name\": \"Arch\", \"description\": \"A powerful dragon roaming the world for worthy opponents.\"}]}. The character that is healed is Alpha Werewolf Martin.", + "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury. Example: Find the character corresponding to the following content: \"As Peter, I wield my powered-up energy sword causing the flesh from my fingers to splinter. I pass by Nyanko, the Swift, as I head forwards towards the Ancient Tower. \" Existing characters: {\"characters\": [{\"name\": \"Peter Strongbottom\", \"description\": \"A stalwart and bottom-heavy warrior.\"}, {\"name\": \"Nyanko, the Swift\", \"description\": \"A nimble and agile rogue.\"}]}. The player character is Peter Strongbottom. First-person pronouns refer to them. Expected result: The character that is hurt is Peter Strongbottom. Another Example: Find the character corresponding to the following content: \"I accidentally step on a bear trap. \" Existing characters: {\"characters\": [{\"name\": \"Tobias Baldin\", \"description\": \"A stalwart and balding warrior.\"}]}. The player character is Tobias Baldin. First-person pronouns refer to them. Expected result: The character that is hurt is Tobias Baldin", + "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting. Example: Find the character corresponding to the following content: I cast a healing spell on Martin in order to restore his wounds he received from fighting off Arch. Existing characters: {\"characters\": [{\"name\": \"Alpha Werewolf Martin\", \"description\": \"A ferocious and rabid werewolf.\"}, {\"name\": \"Kristoffer, the Submissive\", \"description\": \"The most submissive healer in the kingdom\"},{\"name\": \"Arch\", \"description\": \"A powerful dragon roaming the world for worthy opponents.\"}]}. The player character is Peter Kristoffer, the Submissive. First-person pronouns refer to them. Expected result: The character that is healed is Alpha Werewolf Martin. Another Example: Find the character corresponding to the following content: \"I drink a healing potion. \" Existing characters: {\"characters\": [{\"name\": \"Tobias Baldin\", \"description\": \"A stalwart and balding warrior.\"}]}. The player character is Tobias Baldin. First-person pronouns refer to them. Expected result: The character that is healed is Tobias Baldin", "BattleInstruction": "Find the character that will be involved in a battle or combat. Example: Find the character corresponding to the following JSON description: {\"name\": \"Ivan\", \"description\": \"The wielder of Earth, Wind, and Fire.\"}. Existing characters: {\"characters\": [{\"name\": \"Ivan Quintessence, the Magician of Elements\", \"description\": \"A powerful magician that has mastered the elements of Earth, Wind, and Fire\", \"type\": \"Humanoid\"}]. In this case the input character Ivan partially matches the existing character Ivan Quintessence, the Magician of Elements and should therefore be selected." } } From abd1cd76cc7a826cfdcc2f8e94df58db597d2c43 Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Mon, 11 Nov 2024 16:44:43 +0100 Subject: [PATCH 47/51] changed example to avoid degen output --- ChatRPG/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 91b4b68..35f7907 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -25,7 +25,7 @@ "DoAction": "The player has input an action that they would like to perform. You must describe everything that happens as the player completes this action. You may have the player say and do anything as long as it is in character. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction} Once you have determined the correct character, you must return only its exact name, description, and type which you have found in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object as such \"{}\". Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The match is still valid if a partial match in name or description is possible. Character names and descriptions given as context can be shortened, so partial matches must be made in such cases. The context messages only serve to give a hint of the current scenario.", - "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury. Example: Find the character corresponding to the following content: \"As Peter, I wield my powered-up energy sword causing the flesh from my fingers to splinter. I pass by Nyanko, the Swift, as I head forwards towards the Ancient Tower. \" Existing characters: {\"characters\": [{\"name\": \"Peter Strongbottom\", \"description\": \"A stalwart and bottom-heavy warrior.\"}, {\"name\": \"Nyanko, the Swift\", \"description\": \"A nimble and agile rogue.\"}]}. The player character is Peter Strongbottom. First-person pronouns refer to them. Expected result: The character that is hurt is Peter Strongbottom. Another Example: Find the character corresponding to the following content: \"I accidentally step on a bear trap. \" Existing characters: {\"characters\": [{\"name\": \"Tobias Baldin\", \"description\": \"A stalwart and balding warrior.\"}]}. The player character is Tobias Baldin. First-person pronouns refer to them. Expected result: The character that is hurt is Tobias Baldin", + "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury. Example: Find the character corresponding to the following content: \"As Peter, I wield my powered-up energy sword causing the flesh from my fingers to splinter. I pass by Nyanko, the Swift, as I head forwards towards the Ancient Tower. \" Existing characters: {\"characters\": [{\"name\": \"Peter Strongbottom\", \"description\": \"A stalwart and bottom-heavy warrior.\"}, {\"name\": \"Nyanko, the Swift\", \"description\": \"A nimble and agile rogue.\"}]}. The player character is Peter Strongbottom. First-person pronouns refer to them. Expected result: The character that is hurt is Peter Strongbottom. Another Example: Find the character corresponding to the following content: \"I accidentally step on a bear trap. \" Existing characters: {\"characters\": [{\"name\": \"Tobias Baldin\", \"description\": \"A balding adventurer equipped with an axe and a gleaming shield.\"}]}. The player character is Tobias Baldin. First-person pronouns refer to them. Expected result: The character that is hurt is Tobias Baldin", "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting. Example: Find the character corresponding to the following content: I cast a healing spell on Martin in order to restore his wounds he received from fighting off Arch. Existing characters: {\"characters\": [{\"name\": \"Alpha Werewolf Martin\", \"description\": \"A ferocious and rabid werewolf.\"}, {\"name\": \"Kristoffer, the Submissive\", \"description\": \"The most submissive healer in the kingdom\"},{\"name\": \"Arch\", \"description\": \"A powerful dragon roaming the world for worthy opponents.\"}]}. The player character is Peter Kristoffer, the Submissive. First-person pronouns refer to them. Expected result: The character that is healed is Alpha Werewolf Martin. Another Example: Find the character corresponding to the following content: \"I drink a healing potion. \" Existing characters: {\"characters\": [{\"name\": \"Tobias Baldin\", \"description\": \"A stalwart and balding warrior.\"}]}. The player character is Tobias Baldin. First-person pronouns refer to them. Expected result: The character that is healed is Tobias Baldin", "BattleInstruction": "Find the character that will be involved in a battle or combat. Example: Find the character corresponding to the following JSON description: {\"name\": \"Ivan\", \"description\": \"The wielder of Earth, Wind, and Fire.\"}. Existing characters: {\"characters\": [{\"name\": \"Ivan Quintessence, the Magician of Elements\", \"description\": \"A powerful magician that has mastered the elements of Earth, Wind, and Fire\", \"type\": \"Humanoid\"}]. In this case the input character Ivan partially matches the existing character Ivan Quintessence, the Magician of Elements and should therefore be selected." } From e4a8270f69cc4a8f8d9277309320ab067b7062c3 Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Mon, 11 Nov 2024 16:48:00 +0100 Subject: [PATCH 48/51] Fixed error in example --- ChatRPG/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ChatRPG/appsettings.json b/ChatRPG/appsettings.json index 35f7907..141393f 100644 --- a/ChatRPG/appsettings.json +++ b/ChatRPG/appsettings.json @@ -26,7 +26,7 @@ "SayAction": "The player has input something that they want to say. You must describe how characters react and what they say. Address the player only in the second person. Always respond in a narrative as the game master in an immersive way.", "FindCharacter": "You are an expert game master in a single-player RPG. You need to find a specific character in a list of characters from the game world based on the following instruction: {instruction} Once you have determined the correct character, you must return only its exact name, description, and type which you have found in the list, in valid JSON format. Format Instructions: Answer only in valid RAW JSON in the format { \"name\": \"The character's name\", \"description\": \"The character's description\", \"type\": \"The character's type\" }. If the character does not match anyone in the list based on the instructions, return an empty JSON object as such \"{}\". Do not return a character if there does not seem to be a match. The match must be between the characters that are present in the game and the given content. The match is still valid if a partial match in name or description is possible. Character names and descriptions given as context can be shortened, so partial matches must be made in such cases. The context messages only serve to give a hint of the current scenario.", "WoundCharacterInstruction": "Find the character that will be hurt or wounded resulting from unnoticed attacks or performing dangerous activities that will lead to injury. Example: Find the character corresponding to the following content: \"As Peter, I wield my powered-up energy sword causing the flesh from my fingers to splinter. I pass by Nyanko, the Swift, as I head forwards towards the Ancient Tower. \" Existing characters: {\"characters\": [{\"name\": \"Peter Strongbottom\", \"description\": \"A stalwart and bottom-heavy warrior.\"}, {\"name\": \"Nyanko, the Swift\", \"description\": \"A nimble and agile rogue.\"}]}. The player character is Peter Strongbottom. First-person pronouns refer to them. Expected result: The character that is hurt is Peter Strongbottom. Another Example: Find the character corresponding to the following content: \"I accidentally step on a bear trap. \" Existing characters: {\"characters\": [{\"name\": \"Tobias Baldin\", \"description\": \"A balding adventurer equipped with an axe and a gleaming shield.\"}]}. The player character is Tobias Baldin. First-person pronouns refer to them. Expected result: The character that is hurt is Tobias Baldin", - "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting. Example: Find the character corresponding to the following content: I cast a healing spell on Martin in order to restore his wounds he received from fighting off Arch. Existing characters: {\"characters\": [{\"name\": \"Alpha Werewolf Martin\", \"description\": \"A ferocious and rabid werewolf.\"}, {\"name\": \"Kristoffer, the Submissive\", \"description\": \"The most submissive healer in the kingdom\"},{\"name\": \"Arch\", \"description\": \"A powerful dragon roaming the world for worthy opponents.\"}]}. The player character is Peter Kristoffer, the Submissive. First-person pronouns refer to them. Expected result: The character that is healed is Alpha Werewolf Martin. Another Example: Find the character corresponding to the following content: \"I drink a healing potion. \" Existing characters: {\"characters\": [{\"name\": \"Tobias Baldin\", \"description\": \"A stalwart and balding warrior.\"}]}. The player character is Tobias Baldin. First-person pronouns refer to them. Expected result: The character that is healed is Tobias Baldin", + "HealCharacterInstruction": "Find the character that will be healed by magical effects such as a healing spell, through consuming a potion, or by resting. Example: Find the character corresponding to the following content: I cast a healing spell on Martin in order to restore his wounds he received from fighting off Arch. Existing characters: {\"characters\": [{\"name\": \"Alpha Werewolf Martin\", \"description\": \"A ferocious and rabid werewolf.\"}, {\"name\": \"Kristoffer, the Submissive\", \"description\": \"The most submissive healer in the kingdom\"},{\"name\": \"Arch\", \"description\": \"A powerful dragon roaming the world for worthy opponents.\"}]}. The player character is Kristoffer, the Submissive. First-person pronouns refer to them. Expected result: The character that is healed is Alpha Werewolf Martin. Another Example: Find the character corresponding to the following content: \"I drink a healing potion. \" Existing characters: {\"characters\": [{\"name\": \"Tobias Baldin\", \"description\": \"A stalwart and balding warrior.\"}]}. The player character is Tobias Baldin. First-person pronouns refer to them. Expected result: The character that is healed is Tobias Baldin", "BattleInstruction": "Find the character that will be involved in a battle or combat. Example: Find the character corresponding to the following JSON description: {\"name\": \"Ivan\", \"description\": \"The wielder of Earth, Wind, and Fire.\"}. Existing characters: {\"characters\": [{\"name\": \"Ivan Quintessence, the Magician of Elements\", \"description\": \"A powerful magician that has mastered the elements of Earth, Wind, and Fire\", \"type\": \"Humanoid\"}]. In this case the input character Ivan partially matches the existing character Ivan Quintessence, the Magician of Elements and should therefore be selected." } } From 4aa9579a18609607d7645a6a6e2976900f7896ff Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Tue, 12 Nov 2024 12:09:18 +0100 Subject: [PATCH 49/51] Made saving into a task so players can input next command without waiting. (This does make it seem a bit weird to the user though as they dont know when the game is finished updating the campaign with new characters.) --- ChatRPG/Pages/CampaignPage.razor.cs | 14 +++++------ ChatRPG/Services/GameInputHandler.cs | 36 +++++++++++++++++++++------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 3667996..259e377 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -75,6 +75,7 @@ protected override async Task OnInitializedAsync() GameInputHandler!.ChatCompletionReceived += OnChatCompletionReceived; GameInputHandler!.ChatCompletionChunkReceived += OnChatCompletionChunkReceived; + GameInputHandler!.CampaignUpdated += OnCampaignUpdated; _pageInitialized = true; } @@ -121,10 +122,6 @@ private async Task InitializeCampaign() "Please try again by reloading the campaign.")); _isWaitingForResponse = false; } - finally - { - UpdateStatsUi(); - } } /// @@ -167,10 +164,6 @@ private async Task SendPrompt() _campaign = await PersistenceService!.LoadFromCampaignIdAsync(_campaign.Id); // Rollback campaign _isWaitingForResponse = false; } - finally - { - UpdateStatsUi(); - } } /// @@ -225,6 +218,11 @@ private void OnChatCompletionChunkReceived(object? sender, ChatCompletionChunkRe Task.Run(() => ScrollToElement(BottomId)); } + private async void OnCampaignUpdated() + { + await InvokeAsync(UpdateStatsUi); + } + private void OnPromptTypeChange(UserPromptType type) { switch (type) diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index f93f4b4..6adb33e 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -2,6 +2,8 @@ using ChatRPG.Data.Models; using ChatRPG.Pages; using ChatRPG.Services.Events; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; namespace ChatRPG.Services; @@ -12,7 +14,8 @@ public class GameInputHandler private readonly GameStateManager _gameStateManager; private readonly bool _streamChatCompletions; private readonly Dictionary _systemPrompts = new(); - + private readonly AutoResetEvent _autoResetEvent = new(true); + public GameInputHandler(ILogger logger, IReActLlmClient llmClient, GameStateManager gameStateManager, IConfiguration configuration) { @@ -40,6 +43,7 @@ public GameInputHandler(ILogger logger, IReActLlmClient llmCli public event EventHandler? ChatCompletionReceived; public event EventHandler? ChatCompletionChunkReceived; + public event Action? CampaignUpdated; private void OnChatCompletionReceived(OpenAiGptMessage message) { @@ -54,6 +58,11 @@ private void OnChatCompletionChunkReceived(bool isStreamingDone, string? chunk = ChatCompletionChunkReceived?.Invoke(this, args); } + private void OnCampaignUpdated() + { + CampaignUpdated?.Invoke(); + } + public async Task HandleUserPrompt(Campaign campaign, UserPromptType promptType, string userInput) { switch (promptType) @@ -79,6 +88,8 @@ public async Task HandleInitialPrompt(Campaign campaign, string initialInput) private async Task GetResponseAndUpdateState(Campaign campaign, string actionPrompt, string input) { + _autoResetEvent.WaitOne(); + if (_streamChatCompletions) { OpenAiGptMessage message = new(MessageRole.Assistant, ""); @@ -88,25 +99,34 @@ private async Task GetResponseAndUpdateState(Campaign campaign, string actionPro { OnChatCompletionChunkReceived(isStreamingDone: false, chunk); } - - await SaveInteraction(campaign, input, message.Content); + OnChatCompletionChunkReceived(isStreamingDone: true); + + _ = Task.Run(async () => + { + await SaveInteraction(campaign, input, message.Content); + _autoResetEvent.Set(); + }); } else { - string response = await _llmClient.GetChatCompletionAsync(campaign, actionPrompt, input); + var response = await _llmClient.GetChatCompletionAsync(campaign, actionPrompt, input); OpenAiGptMessage message = new(MessageRole.Assistant, response); OnChatCompletionReceived(message); - await SaveInteraction(campaign, input, response); + _ = Task.Run(async () => + { + await SaveInteraction(campaign, input, message.Content); + _autoResetEvent.Set(); + }); } } private async Task SaveInteraction(Campaign campaign, string input, string response) { - await _gameStateManager.StoreMessagesInCampaign(campaign, input, response); await _gameStateManager.UpdateCampaignFromNarrative(campaign, input, response); - + OnCampaignUpdated(); + await _gameStateManager.StoreMessagesInCampaign(campaign, input, response); await _gameStateManager.SaveCurrentState(campaign); } -} +} \ No newline at end of file From 169e4899d383d1c0c9e8e7651ce407bbcbb4c33a Mon Sep 17 00:00:00 2001 From: Sarmisuper Date: Tue, 12 Nov 2024 12:10:19 +0100 Subject: [PATCH 50/51] Linter --- ChatRPG/Pages/CampaignPage.razor.cs | 2 +- ChatRPG/Services/GameInputHandler.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 259e377..631a6bd 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -222,7 +222,7 @@ private async void OnCampaignUpdated() { await InvokeAsync(UpdateStatsUi); } - + private void OnPromptTypeChange(UserPromptType type) { switch (type) diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index 6adb33e..4e46db0 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -15,7 +15,7 @@ public class GameInputHandler private readonly bool _streamChatCompletions; private readonly Dictionary _systemPrompts = new(); private readonly AutoResetEvent _autoResetEvent = new(true); - + public GameInputHandler(ILogger logger, IReActLlmClient llmClient, GameStateManager gameStateManager, IConfiguration configuration) { @@ -62,7 +62,7 @@ private void OnCampaignUpdated() { CampaignUpdated?.Invoke(); } - + public async Task HandleUserPrompt(Campaign campaign, UserPromptType promptType, string userInput) { switch (promptType) @@ -99,9 +99,9 @@ private async Task GetResponseAndUpdateState(Campaign campaign, string actionPro { OnChatCompletionChunkReceived(isStreamingDone: false, chunk); } - + OnChatCompletionChunkReceived(isStreamingDone: true); - + _ = Task.Run(async () => { await SaveInteraction(campaign, input, message.Content); From 62202d01b9d999badade36666cc51e31cc47d801 Mon Sep 17 00:00:00 2001 From: KarmaKamikaze Date: Tue, 12 Nov 2024 17:35:53 +0100 Subject: [PATCH 51/51] Add progress spinner to indicate that archivist is working --- ChatRPG/Pages/CampaignPage.razor | 25 +++++++++++++++++-------- ChatRPG/Pages/CampaignPage.razor.cs | 6 +++++- ChatRPG/Services/GameInputHandler.cs | 4 +--- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/ChatRPG/Pages/CampaignPage.razor b/ChatRPG/Pages/CampaignPage.razor index c530b51..cd691c8 100644 --- a/ChatRPG/Pages/CampaignPage.razor +++ b/ChatRPG/Pages/CampaignPage.razor @@ -94,17 +94,10 @@
- - @if (_hasScrollBar) - { - - }
-
+

@_mainCharacter?.Name

@@ -121,6 +114,22 @@
+ +
+ @if (_isArchiving) + { + + Archiving campaign... + + + } +
+ @if (_hasScrollBar) + { + + }
diff --git a/ChatRPG/Pages/CampaignPage.razor.cs b/ChatRPG/Pages/CampaignPage.razor.cs index 631a6bd..840acde 100644 --- a/ChatRPG/Pages/CampaignPage.razor.cs +++ b/ChatRPG/Pages/CampaignPage.razor.cs @@ -19,6 +19,7 @@ public partial class CampaignPage private List _conversation = new(); private string _userInput = ""; private bool _isWaitingForResponse; + private bool _isArchiving; private const string BottomId = "bottom-id"; private Campaign? _campaign; private List _npcList = new(); @@ -192,6 +193,7 @@ private void OnChatCompletionReceived(object? sender, ChatCompletionReceivedEven if (eventArgs.Message.Content != string.Empty) { _isWaitingForResponse = false; + _isArchiving = true; StateHasChanged(); } } @@ -207,6 +209,7 @@ private void OnChatCompletionChunkReceived(object? sender, ChatCompletionChunkRe if (eventArgs.IsStreamingDone) { _isWaitingForResponse = false; + _isArchiving = true; StateHasChanged(); } else if (eventArgs.Chunk is not null) @@ -220,6 +223,7 @@ private void OnChatCompletionChunkReceived(object? sender, ChatCompletionChunkRe private async void OnCampaignUpdated() { + _isArchiving = false; await InvokeAsync(UpdateStatsUi); } @@ -254,4 +258,4 @@ private void UpdateStatsUi() _mainCharacter = _campaign!.Player; StateHasChanged(); } -} \ No newline at end of file +} diff --git a/ChatRPG/Services/GameInputHandler.cs b/ChatRPG/Services/GameInputHandler.cs index 4e46db0..d4639c5 100644 --- a/ChatRPG/Services/GameInputHandler.cs +++ b/ChatRPG/Services/GameInputHandler.cs @@ -2,8 +2,6 @@ using ChatRPG.Data.Models; using ChatRPG.Pages; using ChatRPG.Services.Events; -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Authorization; namespace ChatRPG.Services; @@ -129,4 +127,4 @@ private async Task SaveInteraction(Campaign campaign, string input, string respo await _gameStateManager.StoreMessagesInCampaign(campaign, input, response); await _gameStateManager.SaveCurrentState(campaign); } -} \ No newline at end of file +}