diff --git a/shared/godot/chat/chat_display.gd b/shared/godot/chat/chat_display.gd index 7500789..1becdf0 100644 --- a/shared/godot/chat/chat_display.gd +++ b/shared/godot/chat/chat_display.gd @@ -1,6 +1,7 @@ extends VBoxContainer ## Renders chat messages using a single RichTextLabel with BBCode. ## Handles its own scrolling and text selection — no ScrollContainer needed. +## Supports mark_speaking() to underline the currently-playing TTS sentence. const SELECTION_BG_COLOR := Color("#1A5C56") @@ -12,9 +13,21 @@ const EMOTION_HEX: Dictionary = { "relaxed": "#80CBC4", } +class _Msg: + var role: String # "user" | "assistant" | "error" + var emotion: String # assistant only + var parts: Array[String] # sentences (assistant) or [text] (user/error) + + func _init(p_role: String, p_emotion: String = "") -> void: + role = p_role + emotion = p_emotion + parts = [] + + var _rtl: RichTextLabel -var _has_messages: bool = false -var _current_emotion: String = "neutral" +var _messages: Array[_Msg] = [] +var _speaking_msg: int = -1 +var _speaking_part: int = -1 func setup() -> void: @@ -45,13 +58,115 @@ func setup() -> void: add_child(_rtl) -func _separator() -> void: - if _has_messages: - _rtl.append_text("\n\n") +var message_count: int: + get: + return _messages.size() func add_user_message(text: String) -> void: - _separator() + var msg := _Msg.new("user") + msg.parts.append(text) + _messages.append(msg) + _append_user_block(text) + _scroll_to_bottom() + + +func start_miku_message(emotion: String) -> void: + var msg := _Msg.new("assistant", emotion) + _messages.append(msg) + _append_miku_header(emotion) + + +func append_miku_text(text: String) -> void: + var msg: _Msg = _messages.back() + msg.parts.append(text) + _rtl.append_text("[color=%s]%s[/color]" % [UiTheme.text_primary.to_html(false), _escape(text)]) + _scroll_to_bottom() + + +func update_miku_emotion(emotion: String) -> void: + if _messages.is_empty(): + return + var msg: _Msg = _messages.back() + if msg.role == "assistant": + msg.emotion = emotion + + +func add_error_message(text: String) -> void: + var msg := _Msg.new("error") + msg.parts.append(text) + _messages.append(msg) + _append_error_block(text) + _scroll_to_bottom() + + +func add_assistant_message(text: String, emotion: String = "neutral") -> void: + start_miku_message(emotion) + append_miku_text(text) + + +func mark_speaking(msg_idx: int, part_idx: int) -> void: + _speaking_msg = msg_idx + _speaking_part = part_idx + _render() + + +func clear_messages() -> void: + _rtl.clear() + _messages.clear() + _speaking_msg = -1 + _speaking_part = -1 + + +func _render() -> void: + _rtl.clear() + for i: int in _messages.size(): + var msg: _Msg = _messages[i] + if i > 0: + _rtl.append_text("\n\n") + match msg.role: + "user": + ( + _rtl + . append_text( + ( + "[right][color=%s]You[/color]\n[color=%s]%s[/color][/right]" + % [ + UiTheme.text_muted.to_html(false), + UiTheme.text_primary.to_html(false), + _escape(msg.parts[0]), + ] + ) + ) + ) + "assistant": + var accent_hex: String = EMOTION_HEX.get(msg.emotion, UiTheme.accent.to_html(false)) + _rtl.append_text( + ( + "[color=%s]✦ Miku[/color] [color=%s]● %s[/color]\n" + % [UiTheme.accent.to_html(false), accent_hex, msg.emotion] + ) + ) + for j: int in msg.parts.size(): + var part: String = msg.parts[j] + if i == _speaking_msg and j == _speaking_part: + _rtl.append_text( + "[u][color=%s]%s[/color][/u]" % [UiTheme.text_primary.to_html(false), _escape(part)] + ) + else: + _rtl.append_text( + "[color=%s]%s[/color]" % [UiTheme.text_primary.to_html(false), _escape(part)] + ) + "error": + _rtl.append_text( + "[color=#D45F5F]⚠[/color] [color=#FF9999]%s[/color]" % _escape(msg.parts[0]) + ) + _scroll_to_bottom() + + +func _append_user_block(text: String) -> void: + if _rtl.get_parsed_text().length() > 0: + _rtl.append_text("\n\n") ( _rtl . append_text( @@ -65,47 +180,24 @@ func add_user_message(text: String) -> void: ) ) ) - _has_messages = true - _scroll_to_bottom() -func start_miku_message(emotion: String) -> void: - _current_emotion = emotion +func _append_miku_header(emotion: String) -> void: + if _rtl.get_parsed_text().length() > 0: + _rtl.append_text("\n\n") var accent_hex: String = EMOTION_HEX.get(emotion, UiTheme.accent.to_html(false)) - _separator() _rtl.append_text( ( "[color=%s]✦ Miku[/color] [color=%s]● %s[/color]\n" % [UiTheme.accent.to_html(false), accent_hex, emotion] ) ) - _has_messages = true -func append_miku_text(text: String) -> void: - _rtl.append_text("[color=%s]%s[/color]" % [UiTheme.text_primary.to_html(false), _escape(text)]) - _scroll_to_bottom() - - -func update_miku_emotion(emotion: String) -> void: - _current_emotion = emotion - - -func add_error_message(text: String) -> void: - _separator() +func _append_error_block(text: String) -> void: + if _rtl.get_parsed_text().length() > 0: + _rtl.append_text("\n\n") _rtl.append_text("[color=#D45F5F]⚠[/color] [color=#FF9999]%s[/color]" % _escape(text)) - _has_messages = true - _scroll_to_bottom() - - -func add_assistant_message(text: String, emotion: String = "neutral") -> void: - start_miku_message(emotion) - append_miku_text(text) - - -func clear_messages() -> void: - _rtl.clear() - _has_messages = false func _scroll_to_bottom() -> void: diff --git a/shared/godot/chat/chat_window.gd b/shared/godot/chat/chat_window.gd index f1ce15c..77fcc82 100644 --- a/shared/godot/chat/chat_window.gd +++ b/shared/godot/chat/chat_window.gd @@ -29,6 +29,8 @@ var _status_label: Label var _conversation_list: VBoxContainer var _miku_streaming: bool = false var _replay_regex: RegEx +var _current_miku_msg_idx: int = -1 +var _speaking_sentence_idx: int = -1 func setup() -> void: @@ -37,6 +39,7 @@ func setup() -> void: EventBus.sentence_ready.connect(_on_sentence_ready) EventBus.state_changed.connect(_on_state_changed) EventBus.emotion_changed.connect(_on_emotion_changed) + EventBus.audio_started.connect(_on_audio_started) func _ready() -> void: @@ -140,6 +143,8 @@ func replay_messages(messages: Array[Dictionary]) -> void: func clear_conversation() -> void: _display.clear_messages() _miku_streaming = false + _current_miku_msg_idx = -1 + _speaking_sentence_idx = -1 func show_error(message: String) -> void: @@ -171,6 +176,8 @@ func _on_text_submitted_display(text: String) -> void: func _on_sentence_ready(text: String, emotion: String) -> void: if not _miku_streaming: + _current_miku_msg_idx = _display.message_count + _speaking_sentence_idx = -1 _display.start_miku_message(emotion) _miku_streaming = true var trimmed := text.strip_edges() @@ -178,11 +185,19 @@ func _on_sentence_ready(text: String, emotion: String) -> void: _display.append_miku_text(trimmed + " ") +func _on_audio_started() -> void: + _speaking_sentence_idx += 1 + _display.mark_speaking(_current_miku_msg_idx, _speaking_sentence_idx) + + func _on_state_changed(_from: String, to: String) -> void: if _status_label != null: _status_label.text = STATE_LABELS.get(to, "") if to == "idle": _miku_streaming = false + if to in ["idle", "listening", "processing"]: + _speaking_sentence_idx = -1 + _display.mark_speaking(-1, -1) func _on_emotion_changed(emotion: String) -> void: