使用Textual构建Chatgpt Tui应用程序
#python #tui #chatgpt

tui代表“文本用户界面”。它是指一种主要依赖文本和符号与用户交互的用户界面,而不是图标和图像等视觉元素。 TUI应用程序通常在命令行界面(CLI)环境中运行,这是一种接口,用户通过将命令键入基于文本的接口与计算机进行交互。 TUI应用程序已经存在了很长时间,尽管它们看似古老,但它们比图形用户界面(GUI)应用具有某些优势。这些包括更快的响应时间,较低的系统要求和更容易的自动化。

真的在终端吗?

几个库,例如urwidPyTermGUI,允许在Python中开发TUI应用。为了增强TUI应用程序的功能和美学,它们提供了一些基本,更复杂的公用事业。但是,有一个包裹确实非常出色,甚至可能是如此惊人,以至于它引发了Tui复兴(我真的想在本文的某个地方放置“ Tui Renaissance”)。

textual 是由富有,非常流行的终端文本格式库的创建者Will McGugan创建的软件包。文本实际上是建立在Rich之上的,以支持所有这些可爱的功能,为它们添加交互性,并可以创建更复杂的应用程序和组件。当您第一次遇到文本应用程序的示例时​​(您可以直接在文本repository中找到它们),您可能会想知道:它真的是一个内置的终端吗?是的,是。

文本在不到两年的时间内达到了大约20k github恒星并非偶然。您不会仅仅提供做某事的工具来赢得社区的心。在文本的情况下,显然远不止于此。首先,代码质量非常好;一切都是类型的提示和记录。另一点是使用的实用和简单的文本。这是因为内置功能太多。随时可以将异步支持包括在内。最终,使用文本产生的效果极为显着。总之,您可以轻松,快速地创建有史以来最令人惊叹的TUI应用程序。

chatgpt和文字

使用文本创建TUI应用程序可能是一种有趣而有意义的体验。如果不花费大量时间在“前端”工作中,您可以轻松地创建具有吸引人的界面的东西。在这里,我们将集中精力chatui,这是一个末端Chatgpt应用程序。 chatui将使用openai软件包与Chatgpt引擎连接,并且当然将通过文本进行界面。项目结构中最重要的部分将包括以下内容:

chatui
├─ requirements
│  ├─ base.txt
│  └─ dev.txt
├─ chatui
│  ├─ static
│  │  └─ styles.css
│  ├─ __init__.py
│  ├─ chat.py
│  ├─ tui.py
│  ├─ settings.py
│  └─ main.py
└─ Makefile

避免产生不必要的负担,其中一些额外的信息被隐藏了。尽管如此,要查看一切如何有线,您可以找到完整的项目here

设置聊天

创建一个新的目录并旋转了新的虚拟环境后,要处理的第一件事是依赖项。在这种情况下,只需要两个:textual==0.14.0openai==0.27.1。这些应添加到requirements/base.txt文件中,并使用pip install -r requirements/base.txt命令安装。

接下来是与ChatGpt模型通信所需的OpenAI API密钥。它可以在OpenAI account page上获取。一旦获得,您当然不希望它进入项目存储库。相反,通过运行export OPENAI_KEY=key>,它可以作为环境变量加载,然后可以在chatui/settings.py文件中读取:

import os

OPENAI_KEY = os.environ["OPENAI_KEY"]

就此,第一步是开发实际的聊天逻辑。多亏了openai库,这并不是太困难了。它提供了一种ChatCompletion.acreate方法,该方法接受AI模型的名称和一系列消息。但是,需要一些其他代码来保持对话的整个上下文:

import openai

from chatui import settings

openai.api_key = settings.OPENAI_KEY

class Conversation:
    model: str = "gpt-3.5-turbo"

    def __init__(self) -> None:
        self.messages: list[dict] = []

    async def send(self, message: str) -> list[str]:
        self.messages.append({"role": "user", "content": message})
        r = await openai.ChatCompletion.acreate(
            model=self.model,
            messages=self.messages,
        )
        return [choice["message"]["content"] for choice in r["choices"]]

    def pick_response(self, choice: str) -> None:
        self.messages.append({"role": "assistant", "content": choice})

    def clear(self) -> None:
        self.messages = []

因此,上一类跟踪所有消息,而chatgpt模型完全了解正在进行的对话。有一些值得一提的事情。第一个是OpenAI模型:gpt-3.5-turbo,它为Chatgpt提供动力,也建议在大多数用例中使用。另一个因素是,除文本外,消息还必须包括有关该角色的信息。结果,AI模型将知道谁说了什么。仅依靠消息顺序就太冒险了。最后,Conversation类返回响应选项列表。尽管在大多数情况下(至少在我的经验上),此列表只有一个选项,但send方法返回完整列表并允许用户选择所需的选项。

可能很难相信,但是这32行代码已经可以执行真实的对话。通过使用该应用的alpha版本创建chatui/main.py文件,已经可以在行动中看到它:

import asyncio

from chatui.chat import Conversation

async def main() -> None:
    conversation = Conversation()
    while True:
        msg = input("Type your message: ")
        choices = await conversation.send(msg)
        print("Here are your choices:", choices)
        choice_index = input("Pick your choice: ")
        conversation.pick_response(choices[int(choice_index)])

if __name__ == "__main__":
    asyncio.run(main())

运行python chatui/main.py可以产生以下结果:

$ python chatui/main.py
Type your message: Tell a joke
Here are your choices: ['\\\\n\\\\nWhy did the tomato turn red? \\\\nBecause it saw the salad dressing!']
Pick your choice: 0
Type your message: Tell another one
Here are your choices: ['Why did the chicken cross the playground?\\\\nTo get to the other slide.']
Pick your choice: ^C

如果您运行此脚本有任何问题,请确保:激活虚拟环境;导出API键;如果发生任何“未找到”错误,则导出 pwd命令作为PYTHONPATH环境变量。

这实际上已经满足chatui的功能要求:

  1. 问问题,
  2. 收到答案,
  3. 持久上下文。

准备好聊天处理,下一步是面向用户的部分:tui。

文字行动

使用文本构建用户界面是通过利用库提供的类来完成的。 (令人惊讶的是)App调用了用于创建应用程序的基类,它用于连接不同的组件并处理各种事件和操作。从简单的东西开始,chatui/tui.py将包含以下代码:

from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, Placeholder

class ChatApp(App):
    TITLE = "chatui"
    SUB_TITLE = "ChatGPT directly in your terminal"
    CSS_PATH = "static/styles.css"

    def compose(self) -> ComposeResult:
        yield Header()
        yield Placeholder()
        yield Footer()

现在必须在chatui/main.py文件中运行文本应用程序:

from chatui.tui import ChatApp

if __name__ == "__main__":
    app = ChatApp()
    app.run()

将其实现,您可以使用python chatui/main.py命令启动该应用程序。预期输出如下:

这已经很有趣。 ChatApp类以接口将使用的一些配置开始。然后,compose方法将窗口小部件产生到父容器。在这种情况下,有三个组件:Header包含与配置相关的信息Placeholder,它只是填充空白空间,而Placeholder现在只是底部的贴纸。要退出应用程序,请使用CTRL+C组合。

尽管这是一个非常简单的例子,但它已经为建造更强大的东西打开了大门。

处理输入

chatui的核心功能正在发送消息。没有能力和处理输入的能力,这显然无法完成。为了实现这一目标,chatui需要具有Input组件:

from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, Input, Button
from textual.containers import Container, Horizontal

class ChatApp(App):
    TITLE = "chatui"
    SUB_TITLE = "ChatGPT directly in your terminal"
    CSS_PATH = "static/styles.css"

    def compose(self) -> ComposeResult:
        yield Header()
        with Horizontal(id="input_box"):
            yield Input(placeholder="Enter your message", id="message_input")
            yield Button(label="Send", variant="success", id="send_button")
        yield Footer()

小部件将已经添加到应用程序中,但是为了使它们正确调整和定位,它们需要一些其他CS。 static/styles.css是存储样式的地方:

#input_box {
    dock: bottom;
    height: auto;
    width: 100%;
    margin: 0 0 2 0;
    align_horizontal: center;
}

#message_input {
    width: 50%;
    background: #343a40;
}

#send_button {
    width: auto;
}

最终,这将导致下面看到的接口:

现在看起来好多了,可以快速回顾实际发生的事情。首先,Placeholder小部件被3个不同的组件替换。 Horizontal是一个容器小部件,可以水平组织其中的项目。将其用作上下文管理器(with关键字)只是将小部件传递到容器__init__方法的一种简单方法。最后,在Horizontal容器中,有InputButton小部件,它们是相当不言自明的。

static/styles.css文件中也有一些值得突出显示的事情。除标准CSS代码外,还有其他文字特征的其他内容:

  • dock: bottom属性使容器粘在底部,
  • height: auto指示容器仅采用所需的高度,而不是占据整个区域,
  • 如果容器内部有一些自由空间,则align_horizontal: center会导致项目移至中心。

拥有不错的美学是使此UI可用的第一步。之后,它实际上必须处理输入。值得庆幸的是,Textual提供了各种选择来处理应用程序中发生的事件的选项:

...  # trunkated imports
from textual.widget import Widget

class ChatApp(App):
    ...  # trunkated code

    async def on_button_pressed(self) -> None:
        await self.process_conversation()

    async def on_input_submitted(self) -> None:
        await self.process_conversation()

    async def process_conversation(self) -> None:
        message_input = self.query_one("#message_input", Input)
        # Don't do anything if input is empty
        if message_input.value == "":
            return
        button = self.query_one("#send_button")

        self.toggle_widgets(message_input, button)

        # Clean up the input without triggering events
        with message_input.prevent(Input.Changed):
            message_input.value = ""

    def toggle_widgets(self, *widgets: Widget) -> None:
        for w in widgets:
            w.disabled = not w.disabled

on_button_pressedon_input_submitted方法是提交输入的起点。它们在文本术语中被称为“消息处理者”。这些方法具有其名称的三个元素:

  • on表示方法实际上是消息处理程序,
  • button描述了名称空间:什么产生了消息,
  • pressed命名了消息的类 - 实际发生的事情。

由于这种命名方法,需要相对最小的代码来处理应用程序中的事件。在这种情况下,有两条可能的消息:在集中输入时单击“输入”按钮并按下发送按钮后,要提交输入。在更复杂的情况下,具有许多输入和按钮,代码必须在处理消息时更准确,但是由于chatui只有一个,因此不必担心。

之后是process_conversation方法,它是App域逻辑的根源,并由两个处理程序使用。目前,它没有很多职责,但是有一些有趣的文本代码。它首先使用其ID查找Input小部件。它与搜索数据库以获取特定记录的ORM相当。当获得输入小部件时,代码检查是否包含任何文本;如果没有,则无需执行任何操作。否则,它继续检索下一个组件:Button。还有一种用于切换小部件列表的辅助方法。在此示例中,它将停用输入和按钮组件,以防止用户在等待响应时输入任何内容。该方法以删除输入文本的代码结束。它使用widget.prevent上下文管理器来防止输入中的文本更改时发出事件。

处理程序当前不包含任何与聊天相关的代码,但是它们确实做出了适当的响应,并且可以访问文本和小部件。下一步是显示消息。

动态添加组件

要使聊天功能功能,需要在屏幕上出现消息。使用UI框架时,动态添加内容通常很棘手。但是,借助文字不是火箭科学。您只需要一个容器小部件,您将添加儿童组件。此外,chatui还将为消息提供自定义小部件,因此它比文字更奇特:

...  # trunkated imports
from textual.widgets import Footer, Header, Input, Button, Static
from textual.containers import Horizontal, Container

class MessageBox(Widget):
    def __init__(self, text: str, role: str) -> None:
        self.text = text
        self.role = role
        super().__init__()

    def compose(self) -> ComposeResult:
        yield Static(self.text, classes=f"message {self.role}")

class ChatApp(App):
    ...  # trunkated code

    def compose(self) -> ComposeResult:
        yield Header()
        yield Container(id="conversation_box")  # 🆕
        with Horizontal(id="input_box"):
            yield Input(placeholder="Enter your message", id="message_input")
            yield Button(label="Send", variant="success", id="send_button")
        yield Footer()

    async def process_conversation(self) -> None:
        message_input = self.query_one("#message_input", Input)
        # Don't do anything if input is empty
        if message_input.value == "":
            return
        button = self.query_one("#send_button")
        conversation_box = self.query_one("#conversation_box")  # 🆕

        self.toggle_widgets(message_input, button)

        # 🆕 Create question message, add it to the conversation and scroll down
        message_box = MessageBox(message_input.value, "question")
        conversation_box.mount(message_box)
        conversation_box.scroll_end(animate=False)

        # Clean up the input without triggering events
        with message_input.prevent(Input.Changed):
            message_input.value = ""

        # 🆕 Add answer to the conversation
        conversation_box.mount(
            MessageBox(
                "Answer",
                "answer",
            )
        )

        self.toggle_widgets(message_input, button)
        conversation_box.scroll_end(animate=False)  # 🆕

与上一个示例类似,组件在chatui/styles.css文件中需要一些样式:

/* ... trunkated styling */

MessageBox {
    layout: horizontal;
    height: auto;
    align-horizontal: center;
}

.message {
    width: auto;
    min-width: 25%;
    border: tall black;
    padding: 1 3;
    margin: 1 0;
    background: #343a40;
}

.question {
    margin: 1 25 1 0;
}

.answer {
    margin: 1 0 1 25;
}

启动应用程序和发送几条消息后的输出应如下:

终端中的对话UI还活着。好吧,至少要提出问题的部分还活着,因为现在的回答是静态的。无论如何,将一点点代码添加到应用程序中。首先是一个名为Container的小部件,它只是一个用于持有其他小部件的垂直容器。正如您在屏幕上看到的那样,它占据了标题和输入框之间的整个空间高度。结果,只有该部分是可滚动的,并且输入框粘在底部。

然后,有一个新的自定义MessageBox小部件,可作为静态文本的容器。要正确地渲染组件,它需要两个附加参数。因此,Static小部件尺度和位置正确,并且基于提供的role CSS类具有额外的样式。将其实现,process_conversation方法现在使用mount方法将MessageBox组件插入对话容器中。 scroll_end方法的存在也值得注意 - 每当出现新消息时,它都用于向下滚动到对话容器的末端。

在这一点上,ChatApp似乎具有支持与Chatgpt进行真实对话所需的大多数UI元素和行为。以下步骤是将其与先前开发的Conversation类连接。

将TUI与chatgpt连接

现在,UI完成了,可以最终集成到TUI中。 ChatApp类必须使用实际的Conversation实例来完成此操作:

...  # trunkated imports
from chatui.chat import Conversation

...  # trunkated code

class ChatApp(App):
    ...  # trunkated code

    def on_mount(self) -> None:
        self.conversation = Conversation()
        self.query_one("#message_input", Input).focus()

    async def process_conversation(self) -> None:
        ...  # trunkated code down to cleaning up the input

        # 🆕 Take answer from the chat and add it to the conversation
        choices = await self.conversation.send(message_box.text)
        self.conversation.pick_response(choices[0])
        conversation_box.mount(
            MessageBox(
                choices[0].removeprefix("\\n").removeprefix("\\n"),
                "answer",
            )
        )

        self.toggle_widgets(message_input, button)
        conversation_box.scroll_end(animate=False)

新代码始于Mount事件的on_mount处理程序,该事件首先安装了应用程序时会触发。通过这样做,该应用程序初始化了一个新的对话,并将输入组件重点注意到,以便用户可以立即开始键入。在process_conversation方法中,安装静态答案消息被替换为向chatgpt发送问题并呈现模型的实际响应。由于AI偶尔会响应线路破裂的消息,因此您可能会注意到Double remove_prefix呼叫,以清理文本。当然,这可以用更可靠的东西代替。所有这些都会导致最终结果:

最后,是一种真正的聊天机器人体验,文本管理TUI和Conversation对象处理通信Chatgpt模型并持续存在上下文。

可用性改进

在此阶段最终结果可能是可以接受的,但可以添加或改进一些东西。首先,能够使用CTRL+C组合以外的方法离开应用程序将是很棒的。另一个有用的功能是清除整个对话。这两种都可以通过将自定义键绑定纳入应用程序来完成。然而,存在一个问题:由于输入组件不断集中,因此关键绑定可能无法按计划使用。要解决此问题并使用钥匙绑定添加其他操作,必须进行以下更改:

...  # trunkated imports
from textual.binding import Binding

class FocusableContainer(Container, can_focus=True):  # 🆕
    ...

class MessageBox(Widget, can_focus=True):  # 🆕
    ...  # trunkated code

class ChatApp(App):
    ...  # trunkated code

    BINDINGS = [  # 🆕
        Binding("q", "quit", "Quit", key_display="Q / CTRL+C"),
        ("ctrl+x", "clear", "Clear"),
    ]

    def compose(self) -> ComposeResult:
        yield Header()
        with FocusableContainer(id="conversation_box"):
            yield MessageBox(  # 🆕
                "Welcome to chatui!\\n"
                "Type your question, click enter or 'send' button "
                "and wait for the response.\\n"
                "At the bottom you can find few more helpful commands.",
                role="info",
            )
        with Horizontal(id="input_box"):
            yield Input(placeholder="Enter your message", id="message_input")
            yield Button(label="Send", variant="success", id="send_button")
        yield Footer()

    def action_clear(self) -> None:  # 🆕
        self.conversation.clear()
        conversation_box = self.query_one("#conversation_box")
        conversation_box.remove()
        self.mount(FocusableContainer(id="conversation_box"))

您可以注意到的第一件事是,Container已被自定义的FocusableContainer替换,该FocusableContainerMessageBox窗口小部件一样,现在已使用can_focus=True选项进行了群。结果,现在占用标题和输入框之间空间的组件现在可以在单击后获得焦点,从而导致Input小部件失去焦点。该调整允许使用自定义键绑定,而无需将随机字母输入输入字段。

第二个更改是前面提到的键绑定,该键绑定被配置为类属性。文本自定义键绑定通常被称为三元素元组,键/密钥组合首先出现,动作名称排名第二,描述是最后的。新添加的自定义clear操作就是这种情况。但是,如果您想进一步调整绑定,则可以使用Binding类,该类别接受更多参数。 quit操作就是这种情况,除了q键外,还可以使用CTRL+C组合触发,因此应显示两个选项。

回到clear动作,您可以看到它是由action_clear方法声明的,然后由键绑定识别。它的目的是简单地清除Conversation状态并重新安装对话容器。

最终更新是一种新的MessageBox,它将在对话开始时出现。它可以用一点介绍来填充空白空间。为了使其与其他消息不同,引入了新的CSS课程:

.info {
    width: auto;
    text-align: center;
}

将所有新片段一起在一起,应用程序的最终输出将如下:

您可以看到,在页脚中解释了键绑定,辅助消息直接位于中间,您可以单击任何地方以将焦点从输入组件转换为。

概括

文字是一个非凡的框架。它可以在维护清洁和当前代码的同时创建美丽而强大的TUI应用程序。即使TUI应用程序并未如此广泛使用,也许是由于出色的工具和库,例如Textual,我们也会开始更频繁地看到它们。他们已经证明了为各种后端任务开发一些简单且半高级的用户界面的方便。无论哪种情况,创建一个TUI应用程序无疑都是令人兴奋的体验,可以导致创建真正原始的东西,而文本可能是最好的途径。

下一步

当该应用满足基本要求时,它有可能增加更多。其他功能可能包括,例如,将对话保留在数据库中,并通过选项卡或侧边栏在它们之间移动。它还可以处理Chatgpt提供的多种选择,从而允许用户选择他喜欢的选择。用户界面也可能变得更加复杂和用户友好。不过,这里的主要目标是展示文本的最吸引人和最关键的特征,以及如何利用它们来创造有趣的东西。理想情况下,论文为更大,更精细,更强大的事情奠定了基础。

来源

本文中使用的chatui项目的代码可以在此处找到:link

我也强烈鼓励您看一下文本documentationcode examples和Will's Twitter