breaking(conversation): 💥 Introduce unique UID system for ConversationStore and enforce UID-based serialization in ConversationOrchestrator
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
99146d162a
commit
255c8a537f
3 changed files with 280 additions and 18 deletions
|
|
@ -60,6 +60,7 @@ var _microphone: Node
|
|||
var _stt_client: Node
|
||||
var _tts_client: Node
|
||||
var _llm_client: Node
|
||||
var _store: Node
|
||||
var _regex: RegEx
|
||||
|
||||
|
||||
|
|
@ -68,11 +69,13 @@ func setup(
|
|||
stt_client: Node,
|
||||
tts_client: Node,
|
||||
llm_client: Node,
|
||||
store: Node = null,
|
||||
) -> void:
|
||||
_microphone = microphone
|
||||
_stt_client = stt_client
|
||||
_tts_client = tts_client
|
||||
_llm_client = llm_client
|
||||
_store = store
|
||||
|
||||
_regex = RegEx.new()
|
||||
_regex.compile(EMOTION_PATTERN)
|
||||
|
|
@ -131,15 +134,9 @@ func _on_transcript_ready(text: String) -> void:
|
|||
if _state != "processing":
|
||||
return
|
||||
|
||||
(
|
||||
_history
|
||||
. append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": text,
|
||||
}
|
||||
)
|
||||
)
|
||||
_history.append({"role": "user", "content": text})
|
||||
if _store != null:
|
||||
_store.save_message("user", text)
|
||||
|
||||
_sentence_buffer = ""
|
||||
_current_emotion = "neutral"
|
||||
|
|
@ -151,15 +148,9 @@ func _on_text_submitted(text: String) -> void:
|
|||
if _state == "speaking":
|
||||
_interrupt()
|
||||
|
||||
(
|
||||
_history
|
||||
. append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": text,
|
||||
}
|
||||
)
|
||||
)
|
||||
_history.append({"role": "user", "content": text})
|
||||
if _store != null:
|
||||
_store.save_message("user", text)
|
||||
|
||||
_sentence_buffer = ""
|
||||
_current_emotion = "neutral"
|
||||
|
|
@ -195,6 +186,8 @@ func _on_response_complete(full_text: String) -> void:
|
|||
_sentence_buffer = ""
|
||||
|
||||
_history.append({"role": "assistant", "content": full_text})
|
||||
if _store != null:
|
||||
_store.save_message("assistant", full_text)
|
||||
_trim_history()
|
||||
|
||||
if _state == "speaking" and _tts_client.is_queue_empty():
|
||||
|
|
@ -267,6 +260,21 @@ func _emotion_to_exaggeration(emotion: String) -> float:
|
|||
return EXAGGERATION_MAP.get(emotion, 0.5)
|
||||
|
||||
|
||||
func load_conversation(messages: Array[Dictionary]) -> void:
|
||||
_history = []
|
||||
var start := maxi(0, messages.size() - 20)
|
||||
for i: int in range(start, messages.size()):
|
||||
_history.append(messages[i])
|
||||
|
||||
|
||||
func clear_history() -> void:
|
||||
_history = []
|
||||
_sentence_buffer = ""
|
||||
_current_emotion = "neutral"
|
||||
if _state != "idle":
|
||||
_transition("idle")
|
||||
|
||||
|
||||
func _trim_history() -> void:
|
||||
while _history.size() > 20:
|
||||
_history.pop_front()
|
||||
|
|
|
|||
253
shared/godot/conversation/conversation_store.gd
Normal file
253
shared/godot/conversation/conversation_store.gd
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
extends Node
|
||||
## Persistence layer for conversations.
|
||||
## Each conversation is a JSON file under user://conversations/.
|
||||
## AppState stores only a lightweight index (active ID + metadata list).
|
||||
## Write coalescing: at most one disk write per frame, plus final flush on exit.
|
||||
|
||||
const CONVERSATIONS_DIR: String = "user://conversations"
|
||||
|
||||
var _active_id: String = ""
|
||||
var _active_messages: Array[Dictionary] = []
|
||||
var _dirty: bool = false
|
||||
|
||||
|
||||
func setup() -> void:
|
||||
_ensure_dir()
|
||||
_load_index()
|
||||
get_tree().root.tree_exiting.connect(_flush_sync)
|
||||
|
||||
|
||||
func create_conversation() -> String:
|
||||
if _dirty:
|
||||
_flush()
|
||||
|
||||
var id := str(int(Time.get_unix_time_from_system()))
|
||||
var now := Time.get_datetime_string_from_system(true)
|
||||
|
||||
_active_id = id
|
||||
_active_messages = []
|
||||
_write_conversation_file(id, "", now, [])
|
||||
_update_index_entry(id, "", now, 0)
|
||||
_save_active_id()
|
||||
|
||||
FlightRecorder.record("conversation.created", "New conversation %s" % id)
|
||||
EventBus.conversation_changed.emit(id)
|
||||
return id
|
||||
|
||||
|
||||
func save_message(role: String, content: String) -> void:
|
||||
if _active_id.is_empty():
|
||||
return
|
||||
|
||||
_active_messages.append({"role": role, "content": content})
|
||||
|
||||
if role == "user" and _active_messages.size() == 1:
|
||||
var title := _make_title(content)
|
||||
_update_index_title(_active_id, title)
|
||||
|
||||
_update_index_count(_active_id, _active_messages.size())
|
||||
_mark_dirty()
|
||||
|
||||
|
||||
func load_conversation(id: String) -> Array[Dictionary]:
|
||||
var data := _read_conversation_file(id)
|
||||
if data.is_empty():
|
||||
return []
|
||||
var messages: Array[Dictionary] = []
|
||||
for msg: Dictionary in data.get("messages", []):
|
||||
messages.append(msg)
|
||||
return messages
|
||||
|
||||
|
||||
func switch_to(id: String) -> Array[Dictionary]:
|
||||
if _dirty:
|
||||
_flush()
|
||||
|
||||
var messages := load_conversation(id)
|
||||
_active_id = id
|
||||
_active_messages = messages.duplicate()
|
||||
_save_active_id()
|
||||
|
||||
FlightRecorder.record("conversation.switched", "Switched to %s" % id)
|
||||
EventBus.conversation_changed.emit(id)
|
||||
return messages
|
||||
|
||||
|
||||
func get_active_id() -> String:
|
||||
return _active_id
|
||||
|
||||
|
||||
func get_conversation_list() -> Array[Dictionary]:
|
||||
var index := AppState.get_section("conversations")
|
||||
var list: Array[Dictionary] = []
|
||||
for entry: Dictionary in index.get("list", []):
|
||||
list.append(entry)
|
||||
return list
|
||||
|
||||
|
||||
func delete_conversation(id: String) -> void:
|
||||
var path := "%s/%s.json" % [CONVERSATIONS_DIR, id]
|
||||
if FileAccess.file_exists(path):
|
||||
DirAccess.remove_absolute(path)
|
||||
|
||||
var index := AppState.get_section("conversations")
|
||||
var list: Array = index.get("list", [])
|
||||
var filtered: Array = []
|
||||
for entry: Dictionary in list:
|
||||
if entry.get("id", "") != id:
|
||||
filtered.append(entry)
|
||||
index["list"] = filtered
|
||||
|
||||
if index.get("active_id", "") == id:
|
||||
index["active_id"] = ""
|
||||
_active_id = ""
|
||||
_active_messages = []
|
||||
|
||||
AppState.set_section("conversations", index)
|
||||
FlightRecorder.record("conversation.deleted", "Deleted %s" % id)
|
||||
EventBus.conversation_deleted.emit(id)
|
||||
|
||||
|
||||
# -- Private ------------------------------------------------------------------
|
||||
|
||||
|
||||
func _ensure_dir() -> void:
|
||||
if not DirAccess.dir_exists_absolute(CONVERSATIONS_DIR):
|
||||
DirAccess.make_dir_recursive_absolute(CONVERSATIONS_DIR)
|
||||
|
||||
|
||||
func _load_index() -> void:
|
||||
var index := AppState.get_section("conversations")
|
||||
_active_id = index.get("active_id", "")
|
||||
|
||||
if not _active_id.is_empty():
|
||||
_active_messages = load_conversation(_active_id)
|
||||
if _active_messages.is_empty():
|
||||
_active_id = ""
|
||||
|
||||
|
||||
func _save_active_id() -> void:
|
||||
var index := AppState.get_section("conversations")
|
||||
index["active_id"] = _active_id
|
||||
AppState.set_section("conversations", index)
|
||||
|
||||
|
||||
func _make_title(first_message: String) -> String:
|
||||
var title := first_message.strip_edges()
|
||||
if title.length() > 40:
|
||||
title = title.substr(0, 37) + "..."
|
||||
return title
|
||||
|
||||
|
||||
func _mark_dirty() -> void:
|
||||
if not _dirty:
|
||||
_dirty = true
|
||||
call_deferred("_flush")
|
||||
|
||||
|
||||
func _flush() -> void:
|
||||
if not _dirty:
|
||||
return
|
||||
_dirty = false
|
||||
if _active_id.is_empty():
|
||||
return
|
||||
|
||||
var index := AppState.get_section("conversations")
|
||||
var list: Array = index.get("list", [])
|
||||
var title := ""
|
||||
var created_at := ""
|
||||
for entry: Dictionary in list:
|
||||
if entry.get("id", "") == _active_id:
|
||||
title = entry.get("title", "")
|
||||
created_at = entry.get("created_at", "")
|
||||
break
|
||||
|
||||
_write_conversation_file(_active_id, title, created_at, _active_messages)
|
||||
|
||||
|
||||
func _flush_sync() -> void:
|
||||
if _dirty:
|
||||
_dirty = false
|
||||
if not _active_id.is_empty():
|
||||
_flush()
|
||||
_dirty = false
|
||||
|
||||
|
||||
func _write_conversation_file(
|
||||
id: String,
|
||||
title: String,
|
||||
created_at: String,
|
||||
messages: Array[Dictionary],
|
||||
) -> void:
|
||||
var data := {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"created_at": created_at,
|
||||
"messages": messages,
|
||||
}
|
||||
var json_str := JSON.stringify(data, "\t")
|
||||
var path := "%s/%s.json" % [CONVERSATIONS_DIR, id]
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("ConversationStore: Failed to write %s" % path)
|
||||
return
|
||||
file.store_string(json_str)
|
||||
|
||||
|
||||
func _read_conversation_file(id: String) -> Dictionary:
|
||||
var path := "%s/%s.json" % [CONVERSATIONS_DIR, id]
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return {}
|
||||
var content := file.get_as_text()
|
||||
var json := JSON.new()
|
||||
if json.parse(content) != OK:
|
||||
push_error("ConversationStore: Failed to parse %s" % path)
|
||||
return {}
|
||||
if json.data is Dictionary:
|
||||
return json.data
|
||||
return {}
|
||||
|
||||
|
||||
func _update_index_entry(
|
||||
id: String,
|
||||
title: String,
|
||||
created_at: String,
|
||||
message_count: int,
|
||||
) -> void:
|
||||
var index := AppState.get_section("conversations")
|
||||
if not index.has("list"):
|
||||
index["list"] = []
|
||||
var list: Array = index["list"]
|
||||
(
|
||||
list
|
||||
. push_front(
|
||||
{
|
||||
"id": id,
|
||||
"title": title,
|
||||
"created_at": created_at,
|
||||
"message_count": message_count,
|
||||
}
|
||||
)
|
||||
)
|
||||
AppState.set_section("conversations", index)
|
||||
|
||||
|
||||
func _update_index_title(id: String, title: String) -> void:
|
||||
var index := AppState.get_section("conversations")
|
||||
for entry: Dictionary in index.get("list", []):
|
||||
if entry.get("id", "") == id:
|
||||
entry["title"] = title
|
||||
break
|
||||
AppState.set_section("conversations", index)
|
||||
|
||||
|
||||
func _update_index_count(id: String, count: int) -> void:
|
||||
var index := AppState.get_section("conversations")
|
||||
for entry: Dictionary in index.get("list", []):
|
||||
if entry.get("id", "") == id:
|
||||
entry["message_count"] = count
|
||||
break
|
||||
AppState.set_section("conversations", index)
|
||||
1
shared/godot/conversation/conversation_store.gd.uid
Normal file
1
shared/godot/conversation/conversation_store.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bsk0ji1n3o2yq
|
||||
Loading…
Add table
Reference in a new issue