ui(chat): 💄 Improve message rendering and scroll behavior in ChatDisplay and ChatWindow
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
2c17dbfafd
commit
5eaa3bcbb1
2 changed files with 142 additions and 35 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue