diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7e0ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# Unity generated +[Ll]ibrary/ +[Tt]emp/ +[Oo]bj/ +[Bb]uild/ +[Bb]uilds/ +[Ll]ogs/ +[Uu]ser[Ss]ettings/ + +# MemoryCaptures can get excessive in size. +[Mm]emoryCaptures/ + +# Asset meta data should only be ignored when the corresponding asset is also ignored +!/[Aa]ssets/**/*.meta + +# Uncomment this line if you wish to ignore the asset store tools plugin +# /[Aa]ssets/AssetStoreTools* + +# Autogenerated Jetbrains Rider plugin +[Aa]ssets/Plugins/Editor/JetBrains* + +# Visual Studio cache directory +.vs/ + +# Gradle cache directory +.gradle/ + +# Autogenerated VS/MD/Consulo solution and project files +ExportedObj/ +.consulo/ +*.csproj +*.unityproj +*.sln +*.suo +*.tmp +*.user +*.userprefs +*.pidb +*.booproj +*.svd +*.pdb +*.mdb +*.opendb +*.VC.db + +# Unity3D generated meta files +*.pidb.meta +*.pdb.meta +*.mdb.meta + +# Unity3D generated file on crash reports +sysinfo.txt + +# Builds +*.apk +*.aab +*.unitypackage +*.app + +# Crashlytics generated file +crashlytics-build.properties + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini \ No newline at end of file diff --git a/Documentation/README.md b/Documentation/README.md index 2f2e026..b0ddd3f 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -226,4 +226,1014 @@ async Task SpawnAndChatAsync() ## License -This SDK is licensed under the MIT License. See LICENSE file for details. \ No newline at end of file +This SDK is licensed under the MIT License. See LICENSE file for details. + +--- + +# Enterprise Documentation + +The following sections provide in-depth coverage for production game development. + +## Persistence & Save Systems + +NPCs automatically track conversation history. Save and load NPCs with their complete state: + +```csharp +using UnityEngine; +using System.Collections.Generic; +using System.IO; +using GumYum.NPC; + +public class NPCPersistenceManager : MonoBehaviour +{ + private Dictionary activeNPCs = new Dictionary(); + private Client client; + + void Start() + { + // Initialize client + client = new Client() + { + ApiKey = "your-key", + ApiSecret = "your-secret" + }; + } + + // AUTOMATIC HISTORY TRACKING + async void DemonstrateHistoryTracking() + { + // Spawn an NPC + var npc = await client.NPCs.SpawnAsync("kingdom", 42, 123456789L); + + // Every chat interaction is automatically saved to npc.ChatHistory + + // Method 1: Manual message list with automatic tracking + var response1 = await npc.Chat.CompletionsAsync(new List + { + new ChatMessage { Role = "user", Content = "Hello there!" } + }); + // npc.ChatHistory now contains both user message and NPC response + + // Method 2: Use ChatWithHistoryAsync() for convenience + // This automatically includes all previous messages + var response2 = await npc.ChatWithHistoryAsync("What goods do you sell?"); + var response3 = await npc.ChatWithHistoryAsync("How much for a healing potion?"); + + // Check conversation history + Debug.Log($"Total messages: {npc.ChatHistory.Count}"); + foreach (var msg in npc.ChatHistory) + { + string role = msg.Role == "user" ? "Player" : npc.Name; + Debug.Log($"{role}: {msg.Content}"); + } + + // SAVE NPC WITH FULL CONVERSATION + SaveNPC(npc); + } + + // SAVE SYSTEM INTEGRATION + void SaveNPC(NPC npc) + { + // Convert to dictionary (useful for custom storage) + var npcData = npc.ToDict(); + // Contains: npc_id, name, profession, personality_type, universe_id, + // seed, spawned data, cached status, and full chat_history + + // Convert to JSON string + string jsonStr = npc.ToJson(); + + // Save to PlayerPrefs (simple approach) + PlayerPrefs.SetString($"NPC_{npc.NpcId}", jsonStr); + PlayerPrefs.Save(); + + // Or save to file (persistent data path) + string filePath = Path.Combine(Application.persistentDataPath, $"npc_{npc.NpcId}.json"); + File.WriteAllText(filePath, jsonStr); + + Debug.Log($"Saved {npc.Name} with {npc.ChatHistory.Count} messages"); + } + + // LOAD AND CONTINUE CONVERSATIONS + NPC LoadNPC(int npcId) + { + // Load from PlayerPrefs + string jsonStr = PlayerPrefs.GetString($"NPC_{npcId}", ""); + if (string.IsNullOrEmpty(jsonStr)) + { + // Try loading from file + string filePath = Path.Combine(Application.persistentDataPath, $"npc_{npcId}.json"); + if (File.Exists(filePath)) + { + jsonStr = File.ReadAllText(filePath); + } + } + + if (!string.IsNullOrEmpty(jsonStr)) + { + // Restore NPC with full conversation history + var loadedNPC = NPC.FromJson(jsonStr, client); + activeNPCs[npcId] = loadedNPC; + + Debug.Log($"Loaded {loadedNPC.Name} with {loadedNPC.ChatHistory.Count} messages"); + return loadedNPC; + } + + return null; + } + + // CONTINUE CONVERSATION AFTER LOADING + async void ContinueConversation(int npcId) + { + var npc = LoadNPC(npcId); + if (npc != null) + { + // Continue from where we left off + var response = await npc.ChatWithHistoryAsync("Do you remember our last conversation?"); + Debug.Log($"{npc.Name}: {response.Choices[0].Message.Content}"); + + // NPC will reference previous conversation naturally + } + } + + // UNITY SAVE GAME EXAMPLE + [System.Serializable] + public class GameSaveData + { + public string saveSlot; + public float timestamp; + public PlayerData player; + public Dictionary npcs = new Dictionary(); + } + + [System.Serializable] + public class PlayerData + { + public int level; + public Vector3 position; + public int health; + } + + public void SaveGame(string slotName) + { + var saveData = new GameSaveData + { + saveSlot = slotName, + timestamp = Time.time, + player = new PlayerData + { + level = 10, + position = transform.position, + health = 100 + } + }; + + // Save each active NPC with their conversation + foreach (var kvp in activeNPCs) + { + saveData.npcs[kvp.Key.ToString()] = kvp.Value.ToJson(); + } + + // Serialize game save + string saveJson = JsonUtility.ToJson(saveData, true); + string savePath = Path.Combine(Application.persistentDataPath, $"save_{slotName}.json"); + File.WriteAllText(savePath, saveJson); + + Debug.Log($"Saved game to slot: {slotName}"); + Debug.Log($"- {activeNPCs.Count} NPCs with conversations"); + } + + public void LoadGame(string slotName) + { + string savePath = Path.Combine(Application.persistentDataPath, $"save_{slotName}.json"); + + if (!File.Exists(savePath)) + { + Debug.LogError($"Save file not found: {slotName}"); + return; + } + + string saveJson = File.ReadAllText(savePath); + var saveData = JsonUtility.FromJson(saveJson); + + // Restore player state + transform.position = saveData.player.position; + // ... restore other player data + + // Restore NPCs with their conversations + activeNPCs.Clear(); + foreach (var kvp in saveData.npcs) + { + int npcId = int.Parse(kvp.Key); + var npc = NPC.FromJson(kvp.Value, client); + activeNPCs[npcId] = npc; + Debug.Log($"Loaded {npc.Name} with {npc.ChatHistory.Count} messages"); + } + + Debug.Log($"Loaded game from slot: {slotName}"); + } + + // SCRIPTABLEOBJECT APPROACH (Unity-specific) + [CreateAssetMenu(fileName = "NPCData", menuName = "GumYum/NPC Data")] + public class NPCDataAsset : ScriptableObject + { + public string npcJson; + public int messageCount; + public string lastUpdated; + + public void SaveNPC(NPC npc) + { + npcJson = npc.ToJson(); + messageCount = npc.ChatHistory.Count; + lastUpdated = System.DateTime.Now.ToString(); + + #if UNITY_EDITOR + UnityEditor.EditorUtility.SetDirty(this); + #endif + } + + public NPC LoadNPC(Client client) + { + if (!string.IsNullOrEmpty(npcJson)) + { + return NPC.FromJson(npcJson, client); + } + return null; + } + } +} +``` + +## Modern Async/Await Patterns + +Use C# async/await for cleaner code and better performance: + +```csharp +using System; +using System.Threading.Tasks; +using System.Collections.Generic; +using UnityEngine; +using GumYum.NPC; + +// Modern C# async/await patterns for Unity 2021.3+ +public class AsyncNPCManager : MonoBehaviour +{ + private Client client; + private Dictionary activeNPCs = new Dictionary(); + + async void Start() + { + // Initialize client + client = new Client() + { + ApiKey = "your-key", + ApiSecret = "your-secret", + DebugMode = true + }; + + try + { + // Spawn NPCs concurrently + await SpawnMultipleNPCsAsync(); + + // Chat with all NPCs + await ChatWithAllNPCsAsync("Hello everyone!"); + } + catch (Exception e) + { + Debug.LogError($"Failed to initialize NPCs: {e.Message}"); + } + } + + // CONCURRENT NPC SPAWNING + async Task SpawnMultipleNPCsAsync() + { + var npcIds = new[] { 1001, 1002, 1003, 2001, 2002 }; + var spawnTasks = new List>(); + + // Start all spawn tasks + foreach (long id in npcIds) + { + var task = client.NPCs.SpawnAsync("kingdom", 42, id); + spawnTasks.Add(task); + } + + // Wait for all to complete + var npcs = await Task.WhenAll(spawnTasks); + + // Store spawned NPCs + foreach (var npc in npcs) + { + activeNPCs[npc.NpcId] = npc; + Debug.Log($"Spawned: {npc.Name} - {npc.Profession}"); + } + } + + // CHAT WITH MULTIPLE NPCS + async Task ChatWithAllNPCsAsync(string message) + { + var chatTasks = new List>(); + + // Start chat tasks for all NPCs + foreach (var npc in activeNPCs.Values) + { + var task = npc.ChatWithHistoryAsync(message); + chatTasks.Add(task); + } + + // Wait for all responses + var responses = await Task.WhenAll(chatTasks); + + // Process responses + int i = 0; + foreach (var npc in activeNPCs.Values) + { + var response = responses[i++]; + Debug.Log($"{npc.Name}: {response.Choices[0].Message.Content}"); + + if (npc.HasMoodChanged()) + { + Debug.Log($" Mood: {npc.GetPreviousMood()} → {npc.GetMood()}"); + } + } + } + + // FILTERED SPAWNING WITH RETRY + async Task SpawnFilteredWithRetryAsync( + string universeId, + int seed, + object filters, + int maxRetries = 3) + { + for (int attempt = 0; attempt < maxRetries; attempt++) + { + try + { + var npc = await client.NPCs.SpawnFilteredAsync(universeId, seed, filters); + return npc; + } + catch (Exception e) + { + Debug.LogWarning($"Spawn attempt {attempt + 1} failed: {e.Message}"); + + if (attempt == maxRetries - 1) + throw; + + // Wait before retry with exponential backoff + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt))); + } + } + + return null; + } + + // ERROR HANDLING PATTERNS + async Task SafeNPCInteractionAsync() + { + try + { + // Try to spawn in non-existent universe + var npc = await client.NPCs.SpawnAsync("unknown-universe", 12345, 1L); + } + catch (System.Net.Http.HttpRequestException httpEx) + { + // Handle API errors + Debug.LogError($"API Error: {httpEx.Message}"); + + // Show user-friendly message + ShowErrorUI("Failed to connect to NPC service. Please check your connection."); + } + catch (TaskCanceledException) + { + // Handle timeout + Debug.LogError("Request timed out"); + ShowErrorUI("The request took too long. Please try again."); + } + catch (Exception ex) + { + // Handle unexpected errors + Debug.LogError($"Unexpected error: {ex.Message}"); + ShowErrorUI("Something went wrong. Please try again later."); + } + } + + // CANCELLATION SUPPORT + private System.Threading.CancellationTokenSource cts; + + async Task CancellableOperationAsync() + { + cts = new System.Threading.CancellationTokenSource(); + + try + { + // Set a timeout + cts.CancelAfter(TimeSpan.FromSeconds(30)); + + // Pass cancellation token to async operations + var npc = await client.NPCs.SpawnAsync( + "kingdom", 42, null, + cancellationToken: cts.Token + ); + + var response = await npc.ChatWithHistoryAsync( + "Tell me a long story", + cancellationToken: cts.Token + ); + } + catch (OperationCanceledException) + { + Debug.Log("Operation was cancelled"); + } + } + + void OnDestroy() + { + // Cancel any pending operations + cts?.Cancel(); + cts?.Dispose(); + + // Dispose of the client + client?.Dispose(); + } + + void ShowErrorUI(string message) + { + // Your UI error display logic + Debug.LogError($"UI Error: {message}"); + } +} + +// UNITY-SPECIFIC ASYNC HELPERS +public static class UnityAsyncHelpers +{ + // Convert async operation to Unity Coroutine + public static IEnumerator AsCoroutine(this Task task) + { + while (!task.IsCompleted) + { + yield return null; + } + + if (task.IsFaulted) + { + throw task.Exception.GetBaseException(); + } + } + + // Await with timeout + public static async Task WithTimeout(this Task task, TimeSpan timeout) + { + using (var cts = new System.Threading.CancellationTokenSource()) + { + var delayTask = Task.Delay(timeout, cts.Token); + var completedTask = await Task.WhenAny(task, delayTask); + + if (completedTask == delayTask) + { + throw new TimeoutException("Operation timed out"); + } + + cts.Cancel(); // Cancel the delay task + return await task; + } + } +} +``` + +## Unity Editor Integration + +Custom inspectors and editor windows for easier development: + +```csharp +// Custom Unity Editor tools for GumYum NPCs +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; +using GumYum.NPC; +using GumYum.NPC.Unity; + +// CUSTOM INSPECTOR FOR NPCS +[CustomEditor(typeof(NPCBehaviour))] +public class NPCInspector : Editor +{ + private NPCBehaviour npcBehaviour; + + void OnEnable() + { + npcBehaviour = (NPCBehaviour)target; + } + + public override void OnInspectorGUI() + { + DrawDefaultInspector(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("NPC Information", EditorStyles.boldLabel); + + if (Application.isPlaying && npcBehaviour.CurrentNPC != null) + { + var npc = npcBehaviour.CurrentNPC; + + EditorGUILayout.LabelField("Name:", npc.Name); + EditorGUILayout.LabelField("Profession:", npc.Profession); + EditorGUILayout.LabelField("Personality Type:", npc.PersonalityType.ToString()); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Current State", EditorStyles.boldLabel); + EditorGUILayout.LabelField("Mood:", npc.GetMood()); + EditorGUILayout.LabelField("Stress Level:", npc.GetStressLevel().ToString()); + EditorGUILayout.LabelField("Location:", npc.GetLocation()); + + if (npc.HasMoodChanged()) + { + EditorGUILayout.HelpBox( + $"Mood changed: {npc.GetPreviousMood()} → {npc.GetMood()}\n" + + $"Reason: {npc.GetLastMoodChangeReason()}", + MessageType.Info + ); + } + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Chat History", EditorStyles.boldLabel); + EditorGUILayout.LabelField($"Messages: {npc.ChatHistory.Count}"); + + if (GUILayout.Button("Save NPC to Asset")) + { + SaveNPCAsset(npc); + } + + if (GUILayout.Button("Export Chat History")) + { + ExportChatHistory(npc); + } + } + else if (Application.isPlaying) + { + EditorGUILayout.HelpBox("No NPC spawned yet", MessageType.Info); + } + + if (!Application.isPlaying) + { + EditorGUILayout.HelpBox("Enter Play Mode to spawn NPCs", MessageType.Info); + } + } + + void SaveNPCAsset(NPC npc) + { + var asset = CreateInstance(); + asset.SaveNPC(npc); + + string path = EditorUtility.SaveFilePanelInProject( + "Save NPC Data", + $"NPC_{npc.Name}_{npc.NpcId}", + "asset", + "Save NPC data as ScriptableObject asset" + ); + + if (!string.IsNullOrEmpty(path)) + { + AssetDatabase.CreateAsset(asset, path); + AssetDatabase.SaveAssets(); + EditorUtility.DisplayDialog("Success", "NPC data saved!", "OK"); + } + } + + void ExportChatHistory(NPC npc) + { + string path = EditorUtility.SaveFilePanel( + "Export Chat History", + "", + $"{npc.Name}_chat_history", + "txt" + ); + + if (!string.IsNullOrEmpty(path)) + { + var content = new System.Text.StringBuilder(); + content.AppendLine($"Chat History with {npc.Name}"); + content.AppendLine($"Universe: {npc.UniverseId}"); + content.AppendLine($"NPC ID: {npc.NpcId}"); + content.AppendLine("=" + new string('=', 50)); + content.AppendLine(); + + foreach (var msg in npc.ChatHistory) + { + string speaker = msg.Role == "user" ? "Player" : npc.Name; + content.AppendLine($"{speaker}: {msg.Content}"); + content.AppendLine(); + } + + System.IO.File.WriteAllText(path, content.ToString()); + EditorUtility.DisplayDialog("Success", "Chat history exported!", "OK"); + } + } +} + +// GUMYUM SETTINGS WINDOW +public class GumYumSettingsWindow : EditorWindow +{ + private string apiKey = ""; + private string apiSecret = ""; + private bool showSecret = false; + private Vector2 scrollPos; + + [MenuItem("Window/GumYum NPC/Settings")] + public static void ShowWindow() + { + var window = GetWindow("GumYum NPC Settings"); + window.minSize = new Vector2(400, 300); + } + + void OnEnable() + { + // Load saved credentials + apiKey = EditorPrefs.GetString("GumYum_APIKey", ""); + apiSecret = EditorPrefs.GetString("GumYum_APISecret", ""); + } + + void OnGUI() + { + scrollPos = EditorGUILayout.BeginScrollView(scrollPos); + + GUILayout.Label("GumYum NPC SDK Settings", EditorStyles.boldLabel); + GUILayout.Space(10); + + // API Credentials Section + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUILayout.Label("API Credentials", EditorStyles.boldLabel); + GUILayout.Label("Get your API keys from https://npc.gumyum.com", EditorStyles.miniLabel); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("API Key:", GUILayout.Width(80)); + apiKey = EditorGUILayout.TextField(apiKey); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("API Secret:", GUILayout.Width(80)); + if (showSecret) + { + apiSecret = EditorGUILayout.TextField(apiSecret); + } + else + { + apiSecret = EditorGUILayout.PasswordField(apiSecret); + } + showSecret = GUILayout.Toggle(showSecret, "Show", GUILayout.Width(50)); + EditorGUILayout.EndHorizontal(); + + GUILayout.Space(5); + + if (GUILayout.Button("Save Settings")) + { + SaveSettings(); + } + + EditorGUILayout.EndVertical(); + + GUILayout.Space(10); + + // Test Connection + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUILayout.Label("Test Connection", EditorStyles.boldLabel); + + if (GUILayout.Button("Test API Connection")) + { + TestConnection(); + } + + EditorGUILayout.EndVertical(); + + GUILayout.Space(10); + + // Quick Actions + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUILayout.Label("Quick Actions", EditorStyles.boldLabel); + + if (GUILayout.Button("Create Chat Demo Scene")) + { + CreateChatDemoScene(); + } + + if (GUILayout.Button("Open Documentation")) + { + Application.OpenURL("https://docs.gumyum.com/sdk/unity"); + } + + if (GUILayout.Button("Get API Keys")) + { + Application.OpenURL("https://npc.gumyum.com"); + } + + EditorGUILayout.EndVertical(); + + GUILayout.Space(10); + + // Info box + EditorGUILayout.HelpBox( + "These credentials are saved in Editor preferences and are not included in builds. " + + "For production, set credentials at runtime or use environment variables.", + MessageType.Info + ); + + EditorGUILayout.EndScrollView(); + } + + void SaveSettings() + { + EditorPrefs.SetString("GumYum_APIKey", apiKey); + EditorPrefs.SetString("GumYum_APISecret", apiSecret); + + EditorUtility.DisplayDialog("Success", "Settings saved!", "OK"); + + // If in play mode, update the singleton + if (Application.isPlaying && GumYumUnityClient.Instance != null) + { + GumYumUnityClient.Instance.SetCredentials(apiKey, apiSecret); + } + } + + async void TestConnection() + { + if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(apiSecret)) + { + EditorUtility.DisplayDialog("Error", "Please enter API credentials first", "OK"); + return; + } + + EditorUtility.DisplayProgressBar("Testing Connection", "Connecting to GumYum API...", 0.5f); + + try + { + var client = new Client() + { + ApiKey = apiKey, + ApiSecret = apiSecret + }; + + // Try to list public universes + var universes = await client.Universes.ListPublicAsync(); + + EditorUtility.ClearProgressBar(); + EditorUtility.DisplayDialog( + "Success", + $"Connection successful!\nFound {universes.Count} public universes.", + "OK" + ); + } + catch (Exception e) + { + EditorUtility.ClearProgressBar(); + EditorUtility.DisplayDialog( + "Connection Failed", + $"Failed to connect: {e.Message}", + "OK" + ); + } + } + + void CreateChatDemoScene() + { + // Implementation to create a demo scene with UI setup + EditorUtility.DisplayDialog( + "Demo Scene", + "Check the Samples folder for example scenes!", + "OK" + ); + } +} + +// Example MonoBehaviour for NPCs +public class NPCBehaviour : MonoBehaviour +{ + [HideInInspector] + public NPC CurrentNPC { get; private set; } + + public async void SpawnNPC(string universeId, int seed, long? npcId = null) + { + var client = GumYumUnityClient.Instance.Client; + CurrentNPC = await client.NPCs.SpawnAsync(universeId, seed, npcId); + } +} + +// ScriptableObject for NPC data persistence +[CreateAssetMenu(fileName = "NPCData", menuName = "GumYum/NPC Data")] +public class NPCDataAsset : ScriptableObject +{ + [TextArea(10, 20)] + public string npcJson; + public int messageCount; + public string lastUpdated; + + public void SaveNPC(NPC npc) + { + npcJson = npc.ToJson(); + messageCount = npc.ChatHistory.Count; + lastUpdated = System.DateTime.Now.ToString(); + + #if UNITY_EDITOR + EditorUtility.SetDirty(this); + #endif + } + + public NPC LoadNPC(Client client) + { + return !string.IsNullOrEmpty(npcJson) ? NPC.FromJson(npcJson, client) : null; + } +} +``` + +## Advanced Streaming Implementation + +Real-time streaming with full event handling: + +```csharp +using UnityEngine; +using UnityEngine.UI; +using System.Collections; +using System.Collections.Generic; +using GumYum.NPC; +using GumYum.NPC.Unity; + +public class StreamingChatExample : MonoBehaviour +{ + [Header("UI")] + public Text dialogueText; + public float typewriterSpeed = 0.02f; + + private GumYumUnityClient client; + private NPC currentNPC; + private Coroutine typewriterCoroutine; + + void Start() + { + client = GumYumUnityClient.Instance; + + // Subscribe to streaming events + client.OnDialogueChunkReceived += OnChunkReceived; + client.OnDialogueStreamStarted += OnStreamStarted; + client.OnDialogueStreamEnded += OnStreamEnded; + } + + // STREAMING WITH COROUTINES + IEnumerator StreamingChatCoroutine(NPC npc, string userMessage) + { + var messages = new List + { + new ChatMessage { Role = "user", Content = userMessage } + }; + + // Track streaming state + bool isStreaming = true; + string fullResponse = ""; + + // Set up event handlers + void OnChunk(object sender, string chunk) + { + fullResponse += chunk; + AppendToDialogue(chunk); + } + + void OnStreamEnd(object sender, DialogueStreamEndedEventArgs e) + { + isStreaming = false; + + // Check mood changes + if (npc.HasMoodChanged()) + { + Debug.Log($"Mood changed: {npc.GetPreviousMood()} → {npc.GetMood()}"); + Debug.Log($"Reason: {npc.GetLastMoodChangeReason()}"); + } + } + + // Subscribe to NPC's streaming events + npc.DialogueChunkReceived += OnChunk; + npc.DialogueStreamEnded += OnStreamEnd; + + // Start streaming request + var streamTask = npc.Chat.CompletionsAsync( + messages, + temperature: 0.8, + maxTokens: 2000, + stream: true // Enable streaming + ); + + // Show typing indicator + ShowTypingIndicator(npc.Name); + + // Wait for stream to complete + yield return new WaitUntil(() => !isStreaming || streamTask.IsCompleted); + + // Clean up + npc.DialogueChunkReceived -= OnChunk; + npc.DialogueStreamEnded -= OnStreamEnd; + HideTypingIndicator(); + + // Continue conversation with history + yield return new WaitForSeconds(1f); + + // Use chat_with_history for follow-up + var followUp = npc.ChatWithHistoryAsync( + "Can you elaborate on that?", + stream: true + ); + + // Handle the follow-up stream... + } + + // STREAMING WITH ASYNC/AWAIT + async void StreamingChatAsync(NPC npc, string userMessage) + { + try + { + // Build on conversation history + var response = await npc.ChatWithHistoryAsync( + userMessage, + temperature: 0.8, + maxTokens: 2000, + stream: true + ); + + // Response contains the full text after streaming + Debug.Log($"Full response: {response.Choices[0].Message.Content}"); + + // Check for mood transitions + if (response.MoodTransition != null && response.MoodTransition.Count > 0) + { + var mood = response.MoodTransition; + Debug.Log($"Mood analysis: {mood}"); + } + } + catch (System.Exception e) + { + Debug.LogError($"Streaming failed: {e.Message}"); + } + } + + // UI HELPERS + void AppendToDialogue(string chunk) + { + if (typewriterCoroutine != null) + StopCoroutine(typewriterCoroutine); + + typewriterCoroutine = StartCoroutine(TypewriterEffect(chunk)); + } + + IEnumerator TypewriterEffect(string text) + { + foreach (char c in text) + { + dialogueText.text += c; + yield return new WaitForSeconds(typewriterSpeed); + } + } + + void ShowTypingIndicator(string npcName) + { + dialogueText.text += $"\n{npcName} is typing..."; + } + + void HideTypingIndicator() + { + // Remove typing indicator + string text = dialogueText.text; + int lastNewline = text.LastIndexOf('\n'); + if (lastNewline > 0) + { + dialogueText.text = text.Substring(0, lastNewline); + } + } + + // EVENT HANDLERS + void OnChunkReceived(string chunk) + { + // Global handler for any streaming chunk + // Useful for sound effects or animations + } + + void OnStreamStarted() + { + // Play typing sound + // Show chat bubble animation + } + + void OnStreamEnded( + string fullResponse, + Dictionary context, + Dictionary moodTransition) + { + // Stream complete + // Update UI state + // Process mood changes + } + + void OnDestroy() + { + // Unsubscribe from events + if (client != null) + { + client.OnDialogueChunkReceived -= OnChunkReceived; + client.OnDialogueStreamStarted -= OnStreamStarted; + client.OnDialogueStreamEnded -= OnStreamEnded; + } + } +} +``` \ No newline at end of file