npc-clients-unity/Runtime/Managers.cs

373 lines
No EOL
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace GumYum.NPC {
/// <summary>
/// Manager for NPC-related operations
/// </summary>
public class NPCManager {
private readonly Client _client;
public NPCManager(Client client) { _client = client; }
/// <summary>
/// Spawn a character - returns NPC object
/// Pass null for npcId to spawn a random NPC
/// </summary>
public async Task<NPC>
SpawnAsync(string universeId, int seed, long? npcId = null,
CancellationToken cancellationToken = default) {
var queryParams =
new Dictionary<string, string> { ["universe_id"] = universeId,
["seed"] = seed.ToString() };
string endpoint;
if (npcId.HasValue) {
// Spawn specific NPC
queryParams["npc_id"] = npcId.Value.ToString();
endpoint = "npc";
} else {
// Spawn random NPC - let API handle the randomness
endpoint = "npc/spawn";
}
var response = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, endpoint, null, queryParams, cancellationToken);
// Check if this is a redirect response
if (response.ContainsKey("redirect_url")) {
// Follow the redirect to get actual NPC data
var redirectPath = response["redirect_url"].ToString();
// Strip the /v1/ prefix since client adds it
if (redirectPath.StartsWith("/v1/")) {
redirectPath = redirectPath.Substring(4);
}
// Make the redirect request
var npcData = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, redirectPath, null, null, cancellationToken);
var npc = new NPC(npcData, _client);
_client.RaiseCharacterSpawned(npc);
return npc;
} else {
// Direct response (shouldn't happen with /npc/spawn)
var npc = new NPC(response, _client);
_client.RaiseCharacterSpawned(npc);
return npc;
}
}
/// <summary>
/// Spawn with advanced filters - supports any NPC field
/// Examples:
/// SpawnFilteredAsync("kingdom", 12345, new { profession = new[] {
/// "warrior", "knight" }, location = new[] { "barracks", "castle" } })
/// SpawnFilteredAsync("blade-runner", 67890, new { personality_type = new[]
/// { 8 }, sex = new[] { "female" } })
/// </summary>
public async Task<NPC>
SpawnFilteredAsync(string universeId, int seed, object filters = null,
CancellationToken cancellationToken = default) {
var data = new { universe_id = universeId, world_seed = seed,
filters = filters ?? new {} };
var endpoint = "npc/spawn_filtered";
// spawn_filtered uses POST with data in body
var response = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Post, endpoint, data, null, cancellationToken);
// Check if this is a redirect response
if (response.ContainsKey("redirect_url")) {
// Follow the redirect to get actual NPC data
var redirectPath = response["redirect_url"].ToString();
// Strip the /v1/ prefix since client adds it
if (redirectPath.StartsWith("/v1/")) {
redirectPath = redirectPath.Substring(4);
}
// Make the redirect request
var npcData = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, redirectPath, null, null, cancellationToken);
var npc = new NPC(npcData, _client);
_client.RaiseCharacterSpawned(npc);
return npc;
} else {
// Direct response (shouldn't happen with /npc/spawn_filtered)
var npc = new NPC(response, _client);
_client.RaiseCharacterSpawned(npc);
return npc;
}
}
}
/// <summary>
/// Manager for Universe-related operations
/// </summary>
public class UniverseManager {
private readonly Client _client;
public UniverseManager(Client client) { _client = client; }
/// <summary>
/// List public universes (works with API keys)
/// </summary>
public async Task<List<Dictionary<string, object>>>
ListPublicAsync(CancellationToken cancellationToken = default) {
// API returns {"public_universes": [...]}
var response = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, "public/universes", null, null, cancellationToken);
if (response != null &&
response.TryGetValue("public_universes", out var universesObj)) {
// Handle Newtonsoft.Json types
if (universesObj is Newtonsoft.Json.Linq.JArray jArray) {
return jArray.ToObject<List<Dictionary<string, object>>>();
} else if (universesObj is List<Dictionary<string, object>> list) {
return list;
}
}
return new List<Dictionary<string, object>>();
}
/// <summary>
/// List user's universes (works with JWT from API key exchange)
/// </summary>
public async Task<List<Dictionary<string, object>>>
ListAsync(CancellationToken cancellationToken = default) {
// API returns {"universes": [...]}
var response = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, "universes", null, null, cancellationToken);
if (response != null &&
response.TryGetValue("universes", out var universesObj)) {
// Handle Newtonsoft.Json types
if (universesObj is Newtonsoft.Json.Linq.JArray jArray) {
return jArray.ToObject<List<Dictionary<string, object>>>();
} else if (universesObj is List<Dictionary<string, object>> list) {
return list;
}
}
return new List<Dictionary<string, object>>();
}
/// <summary>
/// Copy a universe (requires user authentication)
/// </summary>
public Task<Dictionary<string, object>>
CopyAsync(string publicUniverseId, string customName = "",
CancellationToken cancellationToken = default) {
throw new NotSupportedException(
"[GumYum] Copy() requires user authentication. Use API keys with a " +
"known universe ID instead.");
}
/// <summary>
/// Get universe details
/// </summary>
public async Task<Dictionary<string, object>>
GetUniverseAsync(string universeId,
CancellationToken cancellationToken = default) {
return await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Get, $"universes/{universeId}", null, null,
cancellationToken);
}
}
/// <summary>
/// Manager for Chat-related operations
/// </summary>
public class ChatManager {
private readonly Client _client;
public ChatManager(Client client) { _client = client; }
/// <summary>
/// Generate dialogue (OpenAI-compatible chat.completions)
/// </summary>
public async Task<ChatCompletionResponse> CompletionsAsync(
List<ChatMessage> messages, Dictionary<string, object> npcParams,
double temperature = 0.8, int maxTokens = 2000, bool stream = false,
CancellationToken cancellationToken = default) {
var data = new Dictionary<string, object> { ["model"] = "gumyum-npc",
["temperature"] = temperature,
["messages"] = messages,
["stream"] = stream,
["npc_params"] = npcParams,
["max_tokens"] = maxTokens };
// Extract npc_id to top level if it exists in npc_params
if (npcParams.ContainsKey("npc_id")) {
data["npc_id"] = npcParams["npc_id"];
}
if (stream) {
throw new NotImplementedException(
"Streaming is handled via CompletionsStreamAsync");
} else {
var response = await _client.RequestAsync<Dictionary<string, object>>(
HttpMethod.Post, "chat/completions", data, null, cancellationToken);
var completionResponse = new ChatCompletionResponse(response);
if (completionResponse.Choices?.Count > 0) {
var firstChoice = completionResponse.Choices[0];
if (firstChoice?.Message == null) {
return completionResponse;
}
var content = firstChoice.Message.Content;
var context = completionResponse.NpcContext;
var moodTransition = completionResponse.MoodTransition;
_client.RaiseDialogueReceived(new DialogueReceivedEventArgs {
Response = content, Context = context, MoodTransition = moodTransition
});
}
return completionResponse;
}
}
/// <summary>
/// Generate dialogue with streaming support
/// </summary>
public async Task CompletionsStreamAsync(
List<ChatMessage> messages, Dictionary<string, object> npcParams,
Action<string> onChunk, double temperature = 0.8, int maxTokens = 2000,
CancellationToken cancellationToken = default) {
var data = new Dictionary<string, object> { ["model"] = "gumyum-npc",
["temperature"] = temperature,
["messages"] = messages,
["stream"] = true,
["npc_params"] = npcParams,
["max_tokens"] = maxTokens };
// Extract npc_id to top level if it exists in npc_params
if (npcParams.ContainsKey("npc_id")) {
data["npc_id"] = npcParams["npc_id"];
}
await _client.RequestStreamAsync(HttpMethod.Post, "chat/completions", data,
null, onChunk, cancellationToken);
}
/// <summary>
/// Generate dialogue (legacy method name for backwards compatibility)
/// </summary>
public Task<ChatCompletionResponse>
ChatAsync(List<ChatMessage> messages, Dictionary<string, object> npcParams,
double temperature = 0.8, int maxTokens = 2000,
CancellationToken cancellationToken = default) {
return CompletionsAsync(messages, npcParams, temperature, maxTokens, false,
cancellationToken);
}
/// <summary>
/// Generate dialogue with streaming support (legacy method)
/// </summary>
public Task ChatStreamAsync(List<ChatMessage> messages,
Dictionary<string, object> npcParams,
Action<string> onChunk, double temperature = 0.8,
int maxTokens = 2000,
CancellationToken cancellationToken = default) {
return CompletionsStreamAsync(messages, npcParams, onChunk, temperature,
maxTokens, cancellationToken);
}
}
/// <summary>
/// Chat message structure
/// </summary>
public class ChatMessage {
public string Role { get; set; }
public string Content { get; set; }
}
/// <summary>
/// Chat completion response
/// </summary>
public class ChatCompletionResponse {
public List<ChatChoice> Choices { get; set; }
public Dictionary<string, object> NpcContext { get; set; }
public Dictionary<string, object> MoodTransition { get; set; }
public ChatCompletionResponse(Dictionary<string, object> data) {
if (data == null) {
Debug.LogError(
"[ChatCompletionResponse] Constructor received null data!");
Choices = new List<ChatChoice>();
return;
}
if (data.ContainsKey("choices")) {
var choicesData = data["choices"] as List<object>;
if (choicesData == null && data["choices"] != null) {
// Try to handle different array types
if (data["choices"] is Newtonsoft.Json.Linq.JArray jArray) {
choicesData = jArray.ToObject<List<object>>();
}
}
Choices = new List<ChatChoice>();
if (choicesData != null) {
foreach (var choice in choicesData) {
Dictionary<string, object> choiceDict = null;
if (choice is Dictionary<string, object> dict) {
choiceDict = dict;
} else if (choice is Newtonsoft.Json.Linq.JObject jObj) {
choiceDict = jObj.ToObject<Dictionary<string, object>>();
}
if (choiceDict != null) {
Choices.Add(new ChatChoice(choiceDict));
}
// Silently skip unparseable choices
}
}
} else {
Choices = new List<ChatChoice>();
}
if (data.ContainsKey("npc_context")) {
NpcContext = data["npc_context"] as Dictionary<string, object>;
}
if (data.ContainsKey("mood_transition")) {
MoodTransition = data["mood_transition"] as Dictionary<string, object>;
}
}
}
/// <summary>
/// Chat choice in completion response
/// </summary>
public class ChatChoice {
public ChatMessage Message { get; set; }
public ChatChoice(Dictionary<string, object> data) {
if (data?.ContainsKey("message") == true) {
var msgData = data["message"] as Dictionary<string, object>;
if (msgData == null &&
data["message"] is Newtonsoft.Json.Linq.JObject jObj) {
msgData = jObj.ToObject<Dictionary<string, object>>();
}
if (msgData != null) {
Message = new ChatMessage {
Role = msgData.GetValueOrDefault("role")?.ToString() ?? "assistant",
Content = msgData.GetValueOrDefault("content")?.ToString() ?? ""
};
}
// Silently handle missing message data
}
}
}
}