542 lines
No EOL
18 KiB
C#
542 lines
No EOL
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Newtonsoft.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using UnityEngine;
|
|
|
|
namespace GumYum.NPC {
|
|
/// <summary>
|
|
/// GumYum NPC Class - Elegant chat interface similar to Python client
|
|
///
|
|
/// This class represents a spawned NPC and provides convenient methods
|
|
/// for chatting with them. Similar to Python's npc.chat.completions()
|
|
///
|
|
/// Usage:
|
|
/// var npc = await client.NPCs.SpawnAsync("universe-id", 12345, 123456789);
|
|
/// var response = await npc.Chat.CompletionsAsync(new[] { new ChatMessage {
|
|
/// Role = "user", Content = "Hello!" } }); Debug.Log($"{npc.Name}:
|
|
/// {response.Choices[0].Message.Content}");
|
|
/// </summary>
|
|
[System.Serializable]
|
|
public class NPC {
|
|
// Events
|
|
public event EventHandler<DialogueReceivedEventArgs> DialogueReceived;
|
|
public event EventHandler<string> DialogueChunkReceived;
|
|
public event EventHandler DialogueStreamStarted;
|
|
public event EventHandler<DialogueStreamEndedEventArgs> DialogueStreamEnded;
|
|
|
|
// NPC Data (from API response) - Serializable for Unity Inspector
|
|
[SerializeField]
|
|
private long npcId;
|
|
[SerializeField]
|
|
private string name = "";
|
|
[SerializeField]
|
|
private string profession = "";
|
|
[SerializeField]
|
|
private int personalityType = 5;
|
|
[SerializeField]
|
|
private string universeId = "";
|
|
[SerializeField]
|
|
private int seed = 0;
|
|
[SerializeField]
|
|
private bool cached = false;
|
|
[SerializeField]
|
|
private string cacheUrl = "";
|
|
|
|
// Properties for public access
|
|
public long NpcId => npcId;
|
|
public string Name => name;
|
|
public string Profession => profession;
|
|
public int PersonalityType => personalityType;
|
|
public string UniverseId => universeId;
|
|
public int Seed => seed;
|
|
public bool Cached => cached;
|
|
public string CacheUrl => cacheUrl;
|
|
|
|
// Spawned data (nested)
|
|
public Dictionary<string, object> Spawned { get; set; } =
|
|
new Dictionary<string, object>();
|
|
|
|
// Current mood state
|
|
public string CurrentMood { get; private set; } = "";
|
|
public string PreviousMood { get; private set; } = "";
|
|
public int StressLevel { get; private set; } = 5;
|
|
public string LastMoodChangeReason { get; private set; } = "";
|
|
public double LastMoodChangeConfidence { get; private set; } = 0.0;
|
|
|
|
// Client reference (not serialized)
|
|
[System.NonSerialized]
|
|
private Client _client;
|
|
|
|
// Chat history for this NPC (array of message dicts with role and content)
|
|
public List<ChatMessage> ChatHistory { get; } = new List<ChatMessage>();
|
|
|
|
// Internal chat manager for this NPC
|
|
public NPCChatManager Chat { get; private set; }
|
|
|
|
public NPC(Dictionary<string, object> npcData, Client client) {
|
|
_client = client;
|
|
SetupFromData(npcData);
|
|
Chat = new NPCChatManager(this);
|
|
ConnectClientSignals();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Setup NPC from API response data
|
|
/// </summary>
|
|
private void SetupFromData(Dictionary<string, object> npcData) {
|
|
// Check if data is nested in "spawned_npc" first
|
|
if (npcData.ContainsKey("spawned_npc")) {
|
|
if (npcData["spawned_npc"] is Dictionary<string, object> spawnedData) {
|
|
npcData = spawnedData;
|
|
} else if (npcData["spawned_npc"] is Newtonsoft.Json.Linq.JObject jObj) {
|
|
npcData = jObj.ToObject<Dictionary<string, object>>();
|
|
}
|
|
}
|
|
|
|
// Handle npc_id conversion - API might return String or int
|
|
if (npcData.TryGetValue("npc_id", out var npcIdRaw)) {
|
|
if (npcIdRaw is Newtonsoft.Json.Linq.JValue jValue) {
|
|
npcId = jValue.ToObject<long>();
|
|
} else {
|
|
npcId = Convert.ToInt64(npcIdRaw);
|
|
}
|
|
}
|
|
|
|
if (npcData.TryGetValue("name", out var nameValue))
|
|
name = nameValue.ToString();
|
|
|
|
if (npcData.TryGetValue("profession", out var professionValue))
|
|
profession = professionValue.ToString();
|
|
|
|
if (npcData.TryGetValue("personality_type", out var personalityTypeValue)) {
|
|
if (personalityTypeValue is Newtonsoft.Json.Linq.JValue ptJValue) {
|
|
personalityType = ptJValue.ToObject<int>();
|
|
} else {
|
|
personalityType = Convert.ToInt32(personalityTypeValue);
|
|
}
|
|
}
|
|
|
|
if (npcData.TryGetValue("universe_id", out var universeIdValue))
|
|
universeId = universeIdValue.ToString();
|
|
|
|
// Try "world_seed" first (what Elixir API returns), then fall back to
|
|
// "seed"
|
|
if (npcData.TryGetValue("world_seed", out var seedValue)) {
|
|
if (seedValue is Newtonsoft.Json.Linq.JValue seedJValue) {
|
|
seed = seedJValue.ToObject<int>();
|
|
} else {
|
|
seed = Convert.ToInt32(seedValue);
|
|
}
|
|
} else if (npcData.TryGetValue("seed", out seedValue)) {
|
|
if (seedValue is Newtonsoft.Json.Linq.JValue seedJValue) {
|
|
seed = seedJValue.ToObject<int>();
|
|
} else {
|
|
seed = Convert.ToInt32(seedValue);
|
|
}
|
|
}
|
|
|
|
if (npcData.TryGetValue("cached", out var cachedValue)) {
|
|
if (cachedValue is Newtonsoft.Json.Linq.JValue cachedJValue) {
|
|
cached = cachedJValue.ToObject<bool>();
|
|
} else {
|
|
cached = Convert.ToBoolean(cachedValue);
|
|
}
|
|
}
|
|
|
|
if (npcData.TryGetValue("cache_url", out var cacheUrlValue))
|
|
cacheUrl = cacheUrlValue.ToString();
|
|
|
|
// Handle spawned data
|
|
if (npcData.TryGetValue("spawned", out var spawned) &&
|
|
spawned is Dictionary<string, object> spawnedDict) {
|
|
Spawned = spawnedDict;
|
|
// Initialize mood from spawned data
|
|
if (spawnedDict.TryGetValue("mood", out var mood))
|
|
CurrentMood = mood.ToString();
|
|
if (spawnedDict.TryGetValue("stress_level", out var stressLevel)) {
|
|
if (stressLevel is Newtonsoft.Json.Linq.JValue slJValue) {
|
|
StressLevel = slJValue.ToObject<int>();
|
|
} else {
|
|
StressLevel = Convert.ToInt32(stressLevel);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convenience method - direct completions alias (like Python
|
|
/// npc.completions())
|
|
/// </summary>
|
|
public Task<ChatCompletionResponse>
|
|
CompletionsAsync(List<ChatMessage> messages, double temperature = 0.8,
|
|
int maxTokens = 2000, bool stream = false,
|
|
CancellationToken cancellationToken = default) {
|
|
return Chat.CompletionsAsync(messages, temperature, maxTokens, stream,
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Chat with conversation history - includes all previous messages in this
|
|
/// session
|
|
/// </summary>
|
|
public Task<ChatCompletionResponse>
|
|
ChatWithHistoryAsync(string message, double temperature = 0.8,
|
|
int maxTokens = 2000, bool stream = false,
|
|
CancellationToken cancellationToken = default) {
|
|
return Chat.ChatWithHistoryAsync(message, temperature, maxTokens, stream,
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear conversation history
|
|
/// </summary>
|
|
public void ClearHistory() { ChatHistory.Clear(); }
|
|
|
|
/// <summary>
|
|
/// Convert chat history to JSON string for saving
|
|
/// </summary>
|
|
public string ChatHistoryToJson() {
|
|
return JsonConvert.SerializeObject(ChatHistory);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert entire NPC data to dictionary for saving
|
|
/// </summary>
|
|
public Dictionary<string, object> ToDict() {
|
|
return new Dictionary<string,
|
|
object> { ["npc_id"] = npcId,
|
|
["name"] = name,
|
|
["profession"] = profession,
|
|
["personality_type"] = personalityType,
|
|
["universe_id"] = universeId,
|
|
["seed"] = seed,
|
|
["spawned"] = Spawned,
|
|
["cached"] = cached,
|
|
["cache_url"] = cacheUrl,
|
|
["chat_history"] = ChatHistory };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert NPC data to JSON string for saving
|
|
/// </summary>
|
|
public string ToJson() { return JsonConvert.SerializeObject(ToDict()); }
|
|
|
|
/// <summary>
|
|
/// Load NPC data from dictionary (e.g., from saved file)
|
|
/// </summary>
|
|
public static NPC FromDict(Dictionary<string, object> data, Client client) {
|
|
return new NPC(data, client);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load NPC data from JSON string
|
|
/// </summary>
|
|
public static NPC FromJson(string jsonStr, Client client) {
|
|
var data =
|
|
JsonConvert.DeserializeObject<Dictionary<string, object>>(jsonStr);
|
|
return FromDict(data, client);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get NPC's current location
|
|
/// </summary>
|
|
public string GetLocation() {
|
|
return Spawned.TryGetValue("location", out var location)
|
|
? location.ToString()
|
|
: "unknown";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get NPC's current mood
|
|
/// </summary>
|
|
public string GetMood() {
|
|
if (!string.IsNullOrEmpty(CurrentMood))
|
|
return CurrentMood;
|
|
return Spawned.TryGetValue("mood", out var mood) ? mood.ToString()
|
|
: "neutral";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get NPC's stress level
|
|
/// </summary>
|
|
public int GetStressLevel() { return StressLevel; }
|
|
|
|
/// <summary>
|
|
/// Get previous mood (before last change)
|
|
/// </summary>
|
|
public string GetPreviousMood() { return PreviousMood; }
|
|
|
|
/// <summary>
|
|
/// Get last mood change reason
|
|
/// </summary>
|
|
public string GetLastMoodChangeReason() { return LastMoodChangeReason; }
|
|
|
|
/// <summary>
|
|
/// Get last mood change confidence
|
|
/// </summary>
|
|
public double GetLastMoodChangeConfidence() {
|
|
return LastMoodChangeConfidence;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if mood has changed
|
|
/// </summary>
|
|
public bool HasMoodChanged() {
|
|
return !string.IsNullOrEmpty(PreviousMood) && PreviousMood != CurrentMood;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get detailed info string
|
|
/// </summary>
|
|
public string GetInfo() {
|
|
return $"{name} the {profession} (Type {personalityType}) at {GetLocation()}, feeling {GetMood()}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get Enneagram type description
|
|
/// </summary>
|
|
public string GetEnneagramDescription() {
|
|
return personalityType switch {
|
|
1 => "The Reformer",
|
|
2 => "The Helper",
|
|
3 => "The Achiever",
|
|
4 => "The Individualist",
|
|
5 => "The Investigator",
|
|
6 => "The Loyalist",
|
|
7 => "The Enthusiast",
|
|
8 => "The Challenger",
|
|
9 => "The Peacemaker",
|
|
_ => "Unknown Type"
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convert NPC to dictionary for API params
|
|
/// </summary>
|
|
public Dictionary<string, object> ToDictionary() {
|
|
return new Dictionary<string, object> {
|
|
["npc_id"] = npcId,
|
|
["universe_id"] = universeId,
|
|
["seed"] = seed
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Save this NPC to the server (persistent storage)
|
|
/// </summary>
|
|
public async
|
|
Task SaveToServerAsync(string customName = "",
|
|
CancellationToken cancellationToken = default) {
|
|
var data = new Dictionary<string, object> { ["universe_id"] = universeId,
|
|
["world_seed"] = seed,
|
|
["npc_id"] = npcId };
|
|
|
|
if (!string.IsNullOrEmpty(customName))
|
|
data["custom_name"] = customName;
|
|
|
|
await _client.RequestAsync<Dictionary<string, object>>(
|
|
System.Net.Http.HttpMethod.Post, "npc/save", data, null,
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Connect to client's dialogue signals to forward them
|
|
/// </summary>
|
|
private void ConnectClientSignals() {
|
|
if (_client != null) {
|
|
_client.DialogueReceived += OnClientDialogueReceived;
|
|
_client.DialogueChunkReceived += OnClientDialogueChunkReceived;
|
|
_client.DialogueStreamStarted += OnClientDialogueStreamStarted;
|
|
_client.DialogueStreamEnded += OnClientDialogueStreamEnded;
|
|
}
|
|
}
|
|
|
|
private void OnClientDialogueReceived(object sender,
|
|
DialogueReceivedEventArgs e) {
|
|
// Only emit if this response is for our NPC
|
|
if (e.Context?.TryGetValue("npc_id", out var npcIdValue) == true) {
|
|
long npcIdLong = 0;
|
|
if (npcIdValue is Newtonsoft.Json.Linq.JValue jValue) {
|
|
npcIdLong = jValue.ToObject<long>();
|
|
} else {
|
|
npcIdLong = Convert.ToInt64(npcIdValue);
|
|
}
|
|
if (npcIdLong == npcId) {
|
|
// Update mood data if transition occurred
|
|
UpdateMoodFromTransition(e.MoodTransition);
|
|
DialogueReceived?.Invoke(this, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnClientDialogueChunkReceived(object sender, string chunk) {
|
|
DialogueChunkReceived?.Invoke(this, chunk);
|
|
}
|
|
|
|
private void OnClientDialogueStreamStarted(object sender, EventArgs e) {
|
|
DialogueStreamStarted?.Invoke(this, e);
|
|
}
|
|
|
|
private void OnClientDialogueStreamEnded(object sender,
|
|
DialogueStreamEndedEventArgs e) {
|
|
// Only emit if this response is for our NPC
|
|
if (e.Context?.TryGetValue("npc_id", out var npcIdValue) == true) {
|
|
long npcIdLong = 0;
|
|
if (npcIdValue is Newtonsoft.Json.Linq.JValue jValue) {
|
|
npcIdLong = jValue.ToObject<long>();
|
|
} else {
|
|
npcIdLong = Convert.ToInt64(npcIdValue);
|
|
}
|
|
if (npcIdLong == npcId) {
|
|
// Update mood data if transition occurred
|
|
UpdateMoodFromTransition(e.MoodTransition);
|
|
DialogueStreamEnded?.Invoke(this, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update internal mood state from mood transition data
|
|
/// </summary>
|
|
private void
|
|
UpdateMoodFromTransition(Dictionary<string, object> moodTransition) {
|
|
if (moodTransition == null || moodTransition.Count == 0)
|
|
return;
|
|
|
|
// Store previous mood if we're changing
|
|
if (moodTransition.TryGetValue("new_mood", out var newMood) &&
|
|
newMood.ToString() != CurrentMood) {
|
|
PreviousMood = CurrentMood;
|
|
CurrentMood = newMood.ToString();
|
|
}
|
|
|
|
// Update stress level
|
|
if (moodTransition.TryGetValue("stress_level", out var stressLevel)) {
|
|
if (stressLevel is Newtonsoft.Json.Linq.JValue slJValue) {
|
|
StressLevel = slJValue.ToObject<int>();
|
|
} else {
|
|
StressLevel = Convert.ToInt32(stressLevel);
|
|
}
|
|
}
|
|
|
|
// Store mood change metadata
|
|
if (moodTransition.TryGetValue("confidence", out var confidence)) {
|
|
if (confidence is Newtonsoft.Json.Linq.JValue confJValue) {
|
|
LastMoodChangeConfidence = confJValue.ToObject<double>();
|
|
} else {
|
|
LastMoodChangeConfidence = Convert.ToDouble(confidence);
|
|
}
|
|
}
|
|
if (moodTransition.TryGetValue("reasoning", out var reasoning)) {
|
|
LastMoodChangeReason = reasoning.ToString();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Chat manager class - provides the .Chat interface
|
|
/// </summary>
|
|
public class NPCChatManager {
|
|
private readonly NPC _npc;
|
|
|
|
public NPCChatManager(NPC npc) { _npc = npc; }
|
|
|
|
/// <summary>
|
|
/// Main chat completion method - matches Python API
|
|
/// </summary>
|
|
public async Task<ChatCompletionResponse>
|
|
CompletionsAsync(List<ChatMessage> messages, double temperature = 0.8,
|
|
int maxTokens = 2000, bool stream = false,
|
|
CancellationToken cancellationToken = default) {
|
|
// Build npc_params with this NPC's context including current mood
|
|
var npcParams =
|
|
new Dictionary<string, object> { ["universe_id"] = _npc.universeId,
|
|
["world_seed"] = _npc.seed,
|
|
["npc_id"] = _npc.npcId,
|
|
["current_mood"] = _npc.GetMood(),
|
|
["stress_level"] =
|
|
_npc.GetStressLevel() };
|
|
|
|
ChatCompletionResponse response;
|
|
|
|
if (stream) {
|
|
// For streaming, we need to handle it differently
|
|
var tcs = new TaskCompletionSource<ChatCompletionResponse>();
|
|
string accumulatedContent = "";
|
|
Dictionary<string, object> lastContext = null;
|
|
Dictionary<string, object> lastMoodTransition = null;
|
|
|
|
await _npc._client.Chat.CompletionsStreamAsync(
|
|
messages, npcParams, chunk => {
|
|
if (chunk == null) // Stream ended
|
|
{
|
|
// Create a response object with the accumulated content
|
|
var streamResponse =
|
|
new ChatCompletionResponse(new Dictionary<string, object> {
|
|
["choices"] =
|
|
new List<object> { new Dictionary<string, object> {
|
|
["message"] = new Dictionary<
|
|
string, object> { ["role"] = "assistant",
|
|
["content"] =
|
|
accumulatedContent }
|
|
} },
|
|
["npc_context"] = lastContext,
|
|
["mood_transition"] = lastMoodTransition
|
|
});
|
|
tcs.SetResult(streamResponse);
|
|
} else {
|
|
accumulatedContent += chunk;
|
|
}
|
|
}, temperature, maxTokens, cancellationToken);
|
|
|
|
response = await tcs.Task;
|
|
} else {
|
|
response = await _npc._client.Chat.CompletionsAsync(
|
|
messages, npcParams, temperature, maxTokens, false,
|
|
cancellationToken);
|
|
}
|
|
|
|
// Update chat history
|
|
if (response?.Choices?.Count > 0) {
|
|
// Add user message(s) to history
|
|
if (messages.Count > 0) {
|
|
var lastUserMsg = messages[messages.Count - 1];
|
|
if (lastUserMsg.Role == "user") {
|
|
_npc.ChatHistory.Add(lastUserMsg);
|
|
}
|
|
}
|
|
|
|
// Add assistant response to history
|
|
var choice = response.Choices[0];
|
|
if (choice.Message != null) {
|
|
_npc.ChatHistory.Add(
|
|
new ChatMessage { Role = choice.Message.Role,
|
|
Content = choice.Message.Content });
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Chat with conversation history - includes all previous messages in this
|
|
/// session
|
|
/// </summary>
|
|
public async Task<ChatCompletionResponse>
|
|
ChatWithHistoryAsync(string message, double temperature = 0.8,
|
|
int maxTokens = 2000, bool stream = false,
|
|
CancellationToken cancellationToken = default) {
|
|
// Build messages array with history plus new message
|
|
var messagesWithHistory = new List<ChatMessage>(_npc.ChatHistory);
|
|
messagesWithHistory.Add(
|
|
new ChatMessage { Role = "user", Content = message });
|
|
|
|
// Use regular completions which will also update history
|
|
return await CompletionsAsync(messagesWithHistory, temperature, maxTokens,
|
|
stream, cancellationToken);
|
|
}
|
|
}
|
|
}
|
|
} |