394 lines
11 KiB
GDScript
394 lines
11 KiB
GDScript
extends Resource
|
|
class_name GumYumNPC
|
|
|
|
## 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.characters.spawn("universe-id", 12345, 123456789)
|
|
## var response = await npc.chat.completions([{"role": "user", "content": "Hello!"}])
|
|
## print(npc.name + ": " + response.choices[0].message.content)
|
|
|
|
signal dialogue_received(response: String, context: Dictionary, mood_transition: Dictionary)
|
|
signal dialogue_chunk_received(chunk: String)
|
|
signal dialogue_stream_started
|
|
signal dialogue_stream_ended(
|
|
full_response: String, context: Dictionary, mood_transition: Dictionary
|
|
)
|
|
|
|
## NPC Data (from API response)
|
|
@export var npc_id: int
|
|
@export var name: String = ""
|
|
@export var profession: String = ""
|
|
@export var personality_type: int = 5
|
|
@export var universe_id: String = ""
|
|
@export var seed: int = 0
|
|
@export var cached: bool = false
|
|
@export var cache_url: String = ""
|
|
|
|
## Spawned data (nested)
|
|
var spawned: Dictionary = {} # Contains location, mood, stress_level
|
|
|
|
## Current mood state
|
|
var current_mood: String = ""
|
|
var previous_mood: String = ""
|
|
var stress_level: int = 5
|
|
var last_mood_change_reason: String = ""
|
|
var last_mood_change_confidence: float = 0.0
|
|
|
|
## Client reference (not exported - set when spawned)
|
|
var client: Node
|
|
|
|
## Chat history for this NPC (array of message dicts with role and content)
|
|
var chat_history: Array = []
|
|
|
|
## Internal chat manager for this NPC
|
|
var _chat_manager: NPCChatManager
|
|
|
|
|
|
## Chat manager class - provides the .chat interface
|
|
class NPCChatManager:
|
|
var npc: GumYumNPC
|
|
|
|
func _init(parent_npc: GumYumNPC):
|
|
npc = parent_npc
|
|
|
|
## Main chat completion method - matches Python API
|
|
func completions(
|
|
messages: Array,
|
|
temperature: float = 0.8,
|
|
max_tokens: int = 2000,
|
|
stream: bool = false,
|
|
callback: Callable = Callable()
|
|
) -> void:
|
|
if not npc.client:
|
|
push_error("NPC has no client reference. Was it spawned properly?")
|
|
if callback.is_valid():
|
|
callback.call({"error": "NPC has no client reference"})
|
|
return
|
|
|
|
# Build npc_params with this NPC's context including current mood
|
|
var npc_params = {
|
|
"universe_id": npc.universe_id,
|
|
"world_seed": npc.seed,
|
|
"npc_id": npc.npc_id,
|
|
"current_mood": npc.get_mood(),
|
|
"stress_level": npc.get_stress_level()
|
|
}
|
|
|
|
# Create a wrapper callback to capture history
|
|
var history_callback = func(response):
|
|
if (
|
|
not response.has("error")
|
|
and response.has("choices")
|
|
and response["choices"].size() > 0
|
|
):
|
|
# Add user message(s) to history
|
|
if messages.size() > 0:
|
|
var last_user_msg = messages[messages.size() - 1]
|
|
if last_user_msg.has("role") and last_user_msg["role"] == "user":
|
|
npc.chat_history.append(last_user_msg)
|
|
|
|
# Add assistant response to history
|
|
var choice = response["choices"][0]
|
|
if choice.has("message"):
|
|
var assistant_msg = choice["message"]
|
|
if assistant_msg.has("role") and assistant_msg.has("content"):
|
|
npc.chat_history.append(
|
|
{"role": assistant_msg["role"], "content": assistant_msg["content"]}
|
|
)
|
|
|
|
# Call original callback
|
|
if callback.is_valid():
|
|
callback.call(response)
|
|
|
|
if stream:
|
|
# Use streaming chat manager
|
|
npc.client.chat.chat_stream(
|
|
messages, npc_params, temperature, history_callback, max_tokens
|
|
)
|
|
else:
|
|
# Use regular chat manager
|
|
npc.client.chat.chat(messages, npc_params, temperature, history_callback, max_tokens)
|
|
|
|
## Chat with conversation history - includes all previous messages in this session
|
|
func chat_with_history(
|
|
message: String,
|
|
temperature: float = 0.8,
|
|
max_tokens: int = 2000,
|
|
stream: bool = false,
|
|
callback: Callable = Callable()
|
|
) -> void:
|
|
# Build messages array with history plus new message
|
|
var messages_with_history = npc.chat_history.duplicate()
|
|
messages_with_history.append({"role": "user", "content": message})
|
|
|
|
# Use regular completions which will also update history
|
|
completions(messages_with_history, temperature, max_tokens, stream, callback)
|
|
|
|
|
|
func _init(npc_data: Dictionary = {}, client_ref: Node = null):
|
|
if npc_data.size() > 0:
|
|
setup_from_data(npc_data)
|
|
if client_ref:
|
|
client = client_ref
|
|
|
|
# Create chat manager
|
|
_chat_manager = NPCChatManager.new(self)
|
|
|
|
|
|
## Setup NPC from API response data
|
|
func setup_from_data(npc_data: Dictionary) -> void:
|
|
# Handle npc_id conversion - API might return String or int
|
|
var npc_id_raw = npc_data.get("npc_id", 0)
|
|
if typeof(npc_id_raw) == TYPE_STRING:
|
|
npc_id = int(npc_id_raw)
|
|
else:
|
|
npc_id = npc_id_raw
|
|
|
|
name = npc_data.get("name", "")
|
|
profession = npc_data.get("profession", "")
|
|
personality_type = npc_data.get("personality_type", 5)
|
|
universe_id = npc_data.get("universe_id", "")
|
|
seed = npc_data.get("seed", 0)
|
|
cached = npc_data.get("cached", false)
|
|
cache_url = npc_data.get("cache_url", "")
|
|
|
|
# Handle spawned data
|
|
if npc_data.has("spawned"):
|
|
spawned = npc_data.spawned
|
|
# Initialize mood from spawned data
|
|
if spawned.has("mood"):
|
|
current_mood = spawned.mood
|
|
if spawned.has("stress_level"):
|
|
stress_level = spawned.stress_level
|
|
|
|
|
|
## Get the chat manager (similar to Python npc.chat)
|
|
func get_chat() -> NPCChatManager:
|
|
return _chat_manager
|
|
|
|
|
|
## Property for easy access (npc.chat.completions())
|
|
var chat: NPCChatManager:
|
|
get:
|
|
return _chat_manager
|
|
|
|
|
|
## Convenience method - direct completions alias (like Python npc.completions())
|
|
func completions(
|
|
messages: Array,
|
|
temperature: float = 0.8,
|
|
max_tokens: int = 2000,
|
|
stream: bool = false,
|
|
callback: Callable = Callable()
|
|
) -> void:
|
|
chat.completions(messages, temperature, max_tokens, stream, callback)
|
|
|
|
|
|
## Chat with conversation history - includes all previous messages in this session
|
|
func chat_with_history(
|
|
message: String,
|
|
temperature: float = 0.8,
|
|
max_tokens: int = 2000,
|
|
stream: bool = false,
|
|
callback: Callable = Callable()
|
|
) -> void:
|
|
chat.chat_with_history(message, temperature, max_tokens, stream, callback)
|
|
|
|
|
|
## Clear conversation history
|
|
func clear_history() -> void:
|
|
chat_history.clear()
|
|
|
|
|
|
## Convert chat history to JSON string for saving
|
|
func chat_history_to_json() -> String:
|
|
return JSON.stringify(chat_history)
|
|
|
|
|
|
## Convert entire NPC data to dictionary for saving
|
|
func to_dict() -> Dictionary:
|
|
return {
|
|
"npc_id": npc_id,
|
|
"name": name,
|
|
"profession": profession,
|
|
"personality_type": personality_type,
|
|
"universe_id": universe_id,
|
|
"seed": seed,
|
|
"spawned": spawned,
|
|
"cached": cached,
|
|
"cache_url": cache_url,
|
|
"chat_history": chat_history
|
|
}
|
|
|
|
|
|
## Convert NPC data to JSON string for saving
|
|
func to_json() -> String:
|
|
return JSON.stringify(to_dict())
|
|
|
|
|
|
## Load NPC data from dictionary (e.g., from saved file)
|
|
static func from_dict(data: Dictionary, client_ref: Node = null) -> GumYumNPC:
|
|
var npc = GumYumNPC.new({}, client_ref)
|
|
npc.npc_id = data.get("npc_id", 0)
|
|
npc.name = data.get("name", "")
|
|
npc.profession = data.get("profession", "")
|
|
npc.personality_type = data.get("personality_type", 5)
|
|
npc.universe_id = data.get("universe_id", "")
|
|
npc.seed = data.get("seed", 0)
|
|
npc.spawned = data.get("spawned", {})
|
|
npc.cached = data.get("cached", false)
|
|
npc.cache_url = data.get("cache_url", "")
|
|
npc.chat_history = data.get("chat_history", [])
|
|
return npc
|
|
|
|
|
|
## Load NPC data from JSON string
|
|
static func from_json(json_str: String, client_ref: Node = null) -> GumYumNPC:
|
|
var json = JSON.new()
|
|
var parse_result = json.parse(json_str)
|
|
if parse_result != OK:
|
|
push_error("Failed to parse NPC JSON: " + json.get_error_message())
|
|
return null
|
|
return from_dict(json.data, client_ref)
|
|
|
|
|
|
## Save NPC to file (example helper)
|
|
func save_to_file(path: String) -> Error:
|
|
var file = FileAccess.open(path, FileAccess.WRITE)
|
|
if file == null:
|
|
return FileAccess.get_open_error()
|
|
file.store_string(to_json())
|
|
file.close()
|
|
return OK
|
|
|
|
|
|
## Load NPC from file (example helper)
|
|
static func load_from_file(path: String, client_ref: Node = null) -> GumYumNPC:
|
|
var file = FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
push_error("Failed to open NPC file: " + path)
|
|
return null
|
|
var json_str = file.get_as_text()
|
|
file.close()
|
|
return from_json(json_str, client_ref)
|
|
|
|
|
|
## Get NPC's current location
|
|
func get_location() -> String:
|
|
return spawned.get("location", "unknown")
|
|
|
|
|
|
## Get NPC's current mood
|
|
func get_mood() -> String:
|
|
if current_mood != "":
|
|
return current_mood
|
|
return spawned.get("mood", "neutral")
|
|
|
|
|
|
## Get NPC's stress level
|
|
func get_stress_level() -> int:
|
|
return stress_level
|
|
|
|
|
|
## Get previous mood (before last change)
|
|
func get_previous_mood() -> String:
|
|
return previous_mood
|
|
|
|
|
|
## Get last mood change reason
|
|
func get_last_mood_change_reason() -> String:
|
|
return last_mood_change_reason
|
|
|
|
|
|
## Get last mood change confidence
|
|
func get_last_mood_change_confidence() -> float:
|
|
return last_mood_change_confidence
|
|
|
|
|
|
## Check if mood has changed
|
|
func has_mood_changed() -> bool:
|
|
return previous_mood != "" and previous_mood != current_mood
|
|
|
|
|
|
## Get detailed info string
|
|
func get_info() -> String:
|
|
return (
|
|
"%s the %s (Type %d) at %s, feeling %s"
|
|
% [name, profession, personality_type, get_location(), get_mood()]
|
|
)
|
|
|
|
|
|
## Save this NPC to the server (persistent storage)
|
|
func save_to_server(custom_name: String = "", callback: Callable = Callable()) -> void:
|
|
if not client:
|
|
push_error("Cannot save NPC: no client reference")
|
|
return
|
|
|
|
var data = {"universe_id": universe_id, "world_seed": seed, "npc_id": npc_id}
|
|
|
|
if custom_name != "":
|
|
data["custom_name"] = custom_name
|
|
|
|
client.request(HTTPClient.METHOD_POST, "npc/save", data, {}, callback)
|
|
|
|
|
|
## Connect to client's dialogue signals to forward them
|
|
func _connect_client_signals() -> void:
|
|
if client and not client.dialogue_received.is_connected(_on_dialogue_received):
|
|
client.dialogue_received.connect(_on_dialogue_received)
|
|
client.dialogue_chunk_received.connect(_on_dialogue_chunk_received)
|
|
client.dialogue_stream_started.connect(_on_dialogue_stream_started)
|
|
client.dialogue_stream_ended.connect(_on_dialogue_stream_ended)
|
|
|
|
|
|
func _on_dialogue_received(
|
|
response: String, context: Dictionary, mood_transition: Dictionary
|
|
) -> void:
|
|
# Only emit if this response is for our NPC
|
|
if context.has("npc_id") and context.npc_id == npc_id:
|
|
# Update mood data if transition occurred
|
|
_update_mood_from_transition(mood_transition)
|
|
dialogue_received.emit(response, context, mood_transition)
|
|
|
|
|
|
func _on_dialogue_chunk_received(chunk: String) -> void:
|
|
dialogue_chunk_received.emit(chunk)
|
|
|
|
|
|
func _on_dialogue_stream_started() -> void:
|
|
dialogue_stream_started.emit()
|
|
|
|
|
|
func _on_dialogue_stream_ended(
|
|
full_response: String, context: Dictionary, mood_transition: Dictionary
|
|
) -> void:
|
|
# Only emit if this response is for our NPC
|
|
if context.has("npc_id") and context.npc_id == npc_id:
|
|
# Update mood data if transition occurred
|
|
_update_mood_from_transition(mood_transition)
|
|
dialogue_stream_ended.emit(full_response, context, mood_transition)
|
|
|
|
|
|
## Update internal mood state from mood transition data
|
|
func _update_mood_from_transition(mood_transition: Dictionary) -> void:
|
|
if mood_transition.is_empty():
|
|
return
|
|
|
|
# Store previous mood if we're changing
|
|
if mood_transition.has("new_mood") and mood_transition.new_mood != current_mood:
|
|
previous_mood = current_mood
|
|
current_mood = mood_transition.new_mood
|
|
|
|
# Update stress level
|
|
if mood_transition.has("stress_level"):
|
|
stress_level = mood_transition.stress_level
|
|
|
|
# Store mood change metadata
|
|
if mood_transition.has("confidence"):
|
|
last_mood_change_confidence = mood_transition.confidence
|
|
if mood_transition.has("reasoning"):
|
|
last_mood_change_reason = mood_transition.reasoning
|