From e435c8b5ecb00ab503eb715c110db0d117ac15da Mon Sep 17 00:00:00 2001 From: Hugo Saporetti Junior Date: Thu, 21 Nov 2024 17:05:49 -0300 Subject: [PATCH] TUI improvements --- src/main/askai/core/askai.py | 2 +- .../core/commander/commands/history_cmd.py | 13 +++- src/main/askai/resources/askai.tcss | 12 +-- src/main/askai/tui/app_icons.py | 7 +- src/main/askai/tui/app_widgets.py | 67 +++++++++++++---- src/main/askai/tui/askai_app.py | 74 ++++++++++--------- 6 files changed, 114 insertions(+), 61 deletions(-) diff --git a/src/main/askai/core/askai.py b/src/main/askai/core/askai.py index cd51dc66..dd5450a8 100644 --- a/src/main/askai/core/askai.py +++ b/src/main/askai/core/askai.py @@ -120,7 +120,7 @@ def app_settings(self) -> list[tuple[str, ...]]: all_settings.append(r) return all_settings - def abort(self, signals: Any, frame: Any) -> None: + def abort(self, signals: Any | None = None, frame: Any | None = None) -> None: """Hook the SIGINT signal for cleanup or execution interruption. If two signals arrive within 1 second, abort the application execution. :param signals: Signal number from the operating system. diff --git a/src/main/askai/core/commander/commands/history_cmd.py b/src/main/askai/core/commander/commands/history_cmd.py index 4eb6a1c5..b4ea5a8e 100644 --- a/src/main/askai/core/commander/commands/history_cmd.py +++ b/src/main/askai/core/commander/commands/history_cmd.py @@ -13,6 +13,8 @@ Copyright (c) 2024, HomeSetup """ from abc import ABC +from typing import Optional + from askai.core.support.shared_instances import shared from askai.core.support.text_formatter import text_formatter from askai.core.support.utilities import display_text @@ -70,21 +72,26 @@ def context_forget(context: str | None = None) -> None: ) @staticmethod - def context_copy(name: str | None = None) -> None: + def context_copy(name: str | None = None) -> Optional[str]: """Copy a context entry to the clipboard. :param name: The name of the context entry to copy. If None, the default context will be copied. """ + copied_text: str | None = None if (name := name.upper()) in shared.context.keys: if (ctx := str(shared.context.flat(name.upper()))) and ( - stripped_role := re.sub(r"^((system|human|assistant):\s*)", "", ctx, flags=re.MULTILINE | re.IGNORECASE) + copied_text := re.sub( + r"^((system|human|AI|assistant):\s*)", "", ctx, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE + ) ): - pyperclip.copy(stripped_role) + pyperclip.copy(copied_text) text_formatter.commander_print(f"`{name}` copied to the clipboard!") else: text_formatter.commander_print(f"There is nothing to copy from `{name}`!") else: text_formatter.commander_print(f"Context name not found: `{name}`!") + return copied_text + @staticmethod def history_list() -> None: """List the input history entries.""" diff --git a/src/main/askai/resources/askai.tcss b/src/main/askai/resources/askai.tcss index 27cf3c35..4a0284cd 100644 --- a/src/main/askai/resources/askai.tcss +++ b/src/main/askai/resources/askai.tcss @@ -1,3 +1,5 @@ +# Styles reference: https://textual.textualize.io/styles + .-hidden { display: none; visibility: hidden; @@ -183,25 +185,25 @@ MarkdownBullet { InputArea { width: 100%; - height: 4; + height: 6; layout: vertical; + border: round #183236; } InputArea > Input { background: #051416; - border: round #183236; padding: 0 1; height: 3; } -InputArea > InputIcons { +InputArea > InputActions { background: #051416; - padding-left: 1; + color: #7FD5AD 70%; height: 1; layout: horizontal; } -InputIcons > MenuIcon { +InputActions > MenuIcon { background: #051416; height: 1; width: 4; diff --git a/src/main/askai/tui/app_icons.py b/src/main/askai/tui/app_icons.py index 58f858be..15238a5e 100644 --- a/src/main/askai/tui/app_icons.py +++ b/src/main/askai/tui/app_icons.py @@ -29,7 +29,7 @@ class AppIcons(Enumeration): HELP = "" SETTINGS = "" INFO = "" - CONSOLE = "" + CONSOLE = "" DEBUG_ON = "" DEBUG_OFF = "" SPEAKING_ON = "墳" @@ -43,9 +43,8 @@ class AppIcons(Enumeration): BUILT_IN_SPEAKER = "" HEADPHONES = "" CLOCK = "" - SEND = "" - STOP = "" - REGENERATE = "" + REGENERATE = "" LIKE = "" DISLIKE = "" COPY_REPLY = "穀" + READ_ALOUD = "" diff --git a/src/main/askai/tui/app_widgets.py b/src/main/askai/tui/app_widgets.py index d455d216..754e87ba 100644 --- a/src/main/askai/tui/app_widgets.py +++ b/src/main/askai/tui/app_widgets.py @@ -12,17 +12,25 @@ Copyright (c) 2024, HomeSetup """ -from askai.tui.app_icons import AppIcons +import os +import re +import tempfile +from textwrap import dedent +from typing import Callable, Optional + from rich.text import Text +from textual import work from textual.app import ComposeResult, RenderResult from textual.containers import Container from textual.events import Click from textual.reactive import Reactive from textual.widget import Widget from textual.widgets import Collapsible, DataTable, Markdown, Static, Input -from textwrap import dedent -from typing import Callable, Optional +from askai.core.commander.commands.history_cmd import HistoryCmd +from askai.core.commander.commands.tts_stt_cmd import TtsSttCmd +from askai.core.support.shared_instances import shared +from askai.tui.app_icons import AppIcons from askai.tui.app_suggester import InputSuggester @@ -96,7 +104,7 @@ class AppInfo(Static): """ ) - def __init__(self, app_info: str): + def __init__(self, app_info: str = ""): super().__init__() self.info_text = app_info @@ -157,20 +165,53 @@ def engine_name(self) -> str: def compose(self) -> ComposeResult: """Called to add widgets to the app.""" suggester = InputSuggester(case_sensitive=False) - yield InputIcons() + yield InputActions() yield Input(placeholder=f"Message {self.engine_name}", suggester=suggester) -class InputIcons(Static): +class InputActions(Static): """Application Input Icons Area.""" - def __init__(self): - super().__init__() + @staticmethod + def copy() -> None: + """TODO""" + HistoryCmd.context_copy("LAST_REPLY") + + def __init__(self, **kwargs): + super().__init__(**kwargs) def compose(self) -> ComposeResult: """Called to add widgets to the app.""" - yield MenuIcon(AppIcons.SEND.value, "Send", self.app.exit) - yield MenuIcon(AppIcons.COPY_REPLY.value, "Copy last response", self.app.exit) - yield MenuIcon(AppIcons.REGENERATE.value, "Regenerate response", self.app.exit) - yield MenuIcon(AppIcons.LIKE.value, "Good response", self.app.exit) - yield MenuIcon(AppIcons.DISLIKE.value, "Bad response", self.app.exit) + yield MenuIcon(AppIcons.READ_ALOUD.value, "Read aloud", self.read_aloud) + yield MenuIcon(AppIcons.COPY_REPLY.value, "Copy", self.copy) + # FIXME Uncomment after the rate functionality is ready + # yield MenuIcon(AppIcons.LIKE.value, "Good response", self.like) + # yield MenuIcon(AppIcons.DISLIKE.value, "Bad response", self.dislike) + # yield MenuIcon(AppIcons.REGENERATE.value, "Regenerate response", self.regenerate) + + @work(thread=True) + def read_aloud(self) -> None: + """TODO""" + if (ctx := str(shared.context.flat("LAST_REPLY"))) and ( + last_reply := re.sub( + r"^((system|human|AI|assistant):\s*)", "", ctx, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE + ) + ): + TtsSttCmd.tts(last_reply.strip(), os.environ.get("TEMP", tempfile.gettempdir()), True) + + def submit(self) -> None: + """TODO""" + line_input: Input = self.app.line_input + self.post_message(Input.Submitted(line_input, line_input.value)) + + def like(self) -> None: + """TODO""" + pass + + def dislike(self) -> None: + """TODO""" + pass + + def regenerate(self) -> None: + """TODO""" + pass diff --git a/src/main/askai/tui/askai_app.py b/src/main/askai/tui/askai_app.py index 4885089e..ae841970 100644 --- a/src/main/askai/tui/askai_app.py +++ b/src/main/askai/tui/askai_app.py @@ -12,18 +12,28 @@ Copyright (c) 2024, HomeSetup """ +from pathlib import Path +from typing import Optional +import logging as log +import os + +from hspylib.core.enums.charset import Charset +from hspylib.core.tools.commons import file_is_not_empty +from hspylib.core.tools.text_tools import ensure_endswith +from hspylib.core.zoned_datetime import DATE_FORMAT, now, TIME_FORMAT +from hspylib.modules.application.version import Version +from hspylib.modules.cli.vt100.vt_color import VtColor +from hspylib.modules.eventbus.event import Event +from textual import on, work +from textual.app import App, ComposeResult +from textual.containers import ScrollableContainer +from textual.widgets import Footer, Input, MarkdownViewer +import nltk + from askai.__classpath__ import classpath from askai.core.askai import AskAi from askai.core.askai_configs import configs -from askai.core.askai_events import ( - AskAiEvents, - ASKAI_BUS_NAME, - REPLY_EVENT, - MIC_LISTENING_EVENT, - DEVICE_CHANGED_EVENT, - MODE_CHANGED_EVENT, - events, -) +from askai.core.askai_events import * from askai.core.askai_messages import msg from askai.core.askai_prompt import prompt from askai.core.commander.commander import commander_help @@ -39,24 +49,7 @@ from askai.tui.app_header import Header from askai.tui.app_icons import AppIcons from askai.tui.app_suggester import InputSuggester -from askai.tui.app_widgets import AppHelp, AppInfo, AppSettings, Splash, InputArea -from hspylib.core.enums.charset import Charset -from hspylib.core.tools.commons import file_is_not_empty -from hspylib.core.tools.text_tools import ensure_endswith -from hspylib.core.zoned_datetime import DATE_FORMAT, now, TIME_FORMAT -from hspylib.modules.application.version import Version -from hspylib.modules.cli.vt100.vt_color import VtColor -from hspylib.modules.eventbus.event import Event -from pathlib import Path -from textual import on, work -from textual.app import App, ComposeResult -from textual.containers import ScrollableContainer -from textual.widgets import Footer, Input, MarkdownViewer -from typing import Optional - -import logging as log -import nltk -import os +from askai.tui.app_widgets import AppHelp, AppInfo, AppSettings, Splash, InputArea, InputActions SOURCE_DIR: Path = classpath.source_path @@ -77,6 +70,7 @@ class AskAiApp(App[None]): ("d", "debugging", " Debugging"), ("s", "speaking", " Speaking"), ("ctrl+l", "ptt", " Push-to-Talk"), + ("ctrl+s", "stop", " Stop-GenAi"), ] # fmt: on @@ -140,6 +134,10 @@ def line_input(self) -> Input: """Get the Input widget.""" return self.query_one(Input) + @property + def input_actions(self): + return self.query_one(InputActions) + @property def suggester(self) -> Optional[InputSuggester]: """Get the Input Suggester.""" @@ -163,7 +161,7 @@ def compose(self) -> ComposeResult: yield Header() with ScrollableContainer(): yield AppSettings() - yield AppInfo("") + yield AppInfo() yield Splash(self.askai.SPLASH) yield AppHelp(commander_help()) yield MarkdownViewer() @@ -215,6 +213,7 @@ def enable_controls(self, enable: bool = True) -> None: """Enable or disable all UI controls, including the header, input, and footer. :param enable: Whether to enable (True) or disable (False) the UI controls (default is True). """ + self.input_actions.set_class(not enable, "-hidden") self.header.disabled = not enable self.line_input.loading = not enable self.footer.disabled = not enable @@ -262,18 +261,23 @@ async def action_ptt(self) -> None: cache.save_input_history(suggestions) self.enable_controls() + async def action_stop(self) -> None: + """Stop generating response.""" + self.askai.abort() + self.enable_controls() + @on(Input.Submitted) async def on_submit(self, submitted: Input.Submitted) -> None: """A coroutine to handle input submission events. :param submitted: The event that contains the submitted data. """ - question: str = submitted.value - self.line_input.clear() - self.display_text(f"{shared.username_md}: {question}") - if self.ask_and_reply(question): - await self.suggester.add_suggestion(question) - suggestions = await self.suggester.suggestions() - cache.save_input_history(suggestions) + if question := submitted.value: + self.line_input.clear() + self.display_text(f"{shared.username_md}: {question}") + if self.ask_and_reply(question): + await self.suggester.add_suggestion(question) + suggestions = await self.suggester.suggestions() + cache.save_input_history(suggestions) async def _write_markdown(self) -> None: """Write buffered text to the markdown file.