diff --git a/README.md b/README.md index 059f68a..95f6362 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,46 @@ # Elia -A work in progress. How far will I go with this? I have no idea... +A terminal ChatGPT client built with Textual + +![img.png](https://github.com/darrenburns/elia/assets/49741340/80453ed8-ec94-4095-b721-89d32d9fc327) + +> **Note** +> Elia is still a work in progress. How far will I go with this? I have no idea... + +## Quickstart + +Install Elia with [pipx](https://github.com/pypa/pipx), set your OpenAI API key environment variable, +and start the app: + +```bash +pipx install git+https://github.com/darrenburns/elia +export OPENAI_API_KEY="xxxxxxxxxxxxxx" +elia +``` + +### Wiping the Chat History + +Chat history is stored in a SQLite database alongside the Elia application. +To wipe the chat history, simply run the db reset command: + +```bash +elia reset +``` + +### Changing the Chat Directive + +By default, Elia's conversations with ChatGPT are primed with a +directive for the GPT model: + +`You are a helpful assistant.` + +This can be changed by setting the `ELIA_DIRECTIVE` environment variable before +starting a new conversation. A directive is set for the lifetime of a conversation. + +```bash +export ELIA_DIRECTIVE="You are a helpful assistant who talks like a pirate." +elia +``` ## Progress videos diff --git a/elia_chat/__main__.py b/elia_chat/__main__.py new file mode 100644 index 0000000..723981b --- /dev/null +++ b/elia_chat/__main__.py @@ -0,0 +1,61 @@ +""" +Elia CLI +""" + +import pathlib + +import click + +from elia_chat.app import app +from elia_chat.database.create_database import create_database +from elia_chat.database.import_chatgpt import import_chatgpt_data +from elia_chat.database.models import sqlite_file_name + + +@click.group(invoke_without_command=True) +@click.pass_context +def cli(context: click.Context) -> None: + """ + Elia: A terminal ChatGPT client built with Textual + """ + # Run the app if no subcommand is provided + if context.invoked_subcommand is None: + # Create the database if it doesn't exist + if sqlite_file_name.exists() is False: + create_database() + app.run() + + +@cli.command() +def reset() -> None: + """ + Reset the database + + This command will delete the database file and recreate it. + Previously saved conversations and data will be lost. + """ + sqlite_file_name.unlink(missing_ok=True) + create_database() + click.echo(f"♻️ Database reset @ {sqlite_file_name}") + + +@cli.command("import") +@click.argument( + "file", + type=click.Path( + exists=True, dir_okay=False, path_type=pathlib.Path, resolve_path=True + ), +) +def import_file_to_db(file) -> None: + """ + Import ChatGPT Conversations + + This command will import the ChatGPT conversations from a local + JSON file into the database. + """ + import_chatgpt_data(file=file) + click.echo(f"✅ ChatGPT data imported into database {file}") + + +if __name__ == "__main__": + cli() diff --git a/elia_chat/app.py b/elia_chat/app.py index 2942055..c396e60 100644 --- a/elia_chat/app.py +++ b/elia_chat/app.py @@ -20,10 +20,5 @@ def on_mount(self) -> None: app = Elia() - -def run(): - app.run() - - if __name__ == "__main__": app.run() diff --git a/elia_chat/database/__init__.py b/elia_chat/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/create_database.py b/elia_chat/database/create_database.py similarity index 72% rename from scripts/create_database.py rename to elia_chat/database/create_database.py index 9e08ebb..eddec0e 100644 --- a/scripts/create_database.py +++ b/elia_chat/database/create_database.py @@ -2,5 +2,10 @@ from elia_chat.database.models import engine -if __name__ == "__main__": + +def create_database() -> None: SQLModel.metadata.create_all(engine) + + +if __name__ == "__main__": + create_database() diff --git a/elia_chat/database/models.py b/elia_chat/database/models.py index 1cc2d6d..f83cecf 100644 --- a/elia_chat/database/models.py +++ b/elia_chat/database/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pathlib from datetime import datetime from typing import Any @@ -70,6 +71,7 @@ def from_id(chat_id: str) -> ChatDao: return result -sqlite_file_name = "elia.sqlite" +_this_dir = pathlib.Path(__file__).resolve().parent +sqlite_file_name = _this_dir / "elia.sqlite" sqlite_url = f"sqlite:///{sqlite_file_name}" engine = create_engine(sqlite_url) diff --git a/elia_chat/widgets/chat.py b/elia_chat/widgets/chat.py index 1b336ee..5fba05c 100644 --- a/elia_chat/widgets/chat.py +++ b/elia_chat/widgets/chat.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import os import time from dataclasses import dataclass from typing import Any @@ -32,6 +33,9 @@ class Chat(Widget): def __init__(self) -> None: super().__init__() + self.persona_directive = os.getenv( + "ELIA_DIRECTIVE", "You are a helpful assistant." + ) # The thread initially only contains the system message. self.chat_container: ScrollableContainer | None = None self.chat_options: ChatOptions | None = None @@ -44,7 +48,7 @@ def __init__(self) -> None: ChatMessage( id=None, role="system", - content="You are a helpful assistant.", + content=self.persona_directive, timestamp=time.time(), status=None, end_turn=None, diff --git a/elia_chat/widgets/chat_list.py b/elia_chat/widgets/chat_list.py index ecf7c01..00db13d 100644 --- a/elia_chat/widgets/chat_list.py +++ b/elia_chat/widgets/chat_list.py @@ -30,8 +30,8 @@ def __rich_console__( ) -> RenderResult: utc_dt = datetime.datetime.utcnow() local_dt = utc_dt.astimezone() - create_time_string = humanize.naturaltime(self.chat.create_time, when=local_dt) - subtitle = f"{create_time_string}" + delta = local_dt - self.chat.create_time + subtitle = humanize.naturaltime(delta) yield Padding( Text.assemble( (self.chat.short_preview, "" if not self.is_open else "b"), diff --git a/poetry.lock b/poetry.lock index 47ac893..b0ce720 100644 --- a/poetry.lock +++ b/poetry.lock @@ -315,14 +315,14 @@ files = [ [[package]] name = "click" -version = "8.1.3" +version = "8.1.6" description = "Composable command line interface toolkit" category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, + {file = "click-8.1.6-py3-none-any.whl", hash = "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5"}, + {file = "click-8.1.6.tar.gz", hash = "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd"}, ] [package.dependencies] @@ -672,7 +672,7 @@ files = [ name = "msgpack" version = "1.0.5" description = "MessagePack serializer" -category = "main" +category = "dev" optional = false python-versions = "*" files = [ @@ -1364,29 +1364,40 @@ sqlalchemy2-stubs = "*" [[package]] name = "textual" -version = "0.26.0" +version = "0.30.0" description = "Modern Text User Interface framework" category = "main" optional = false -python-versions = "^3.7" -files = [] -develop = true +python-versions = ">=3.7,<4.0" +files = [ + {file = "textual-0.30.0-py3-none-any.whl", hash = "sha256:e87d587e4569236f3809d41955ed9556287dbedaca64724e1d6ad5adbb69c9c5"}, + {file = "textual-0.30.0.tar.gz", hash = "sha256:bf7045a7e9b7dc3ac589c38ce86ac31aecf0e76e8c8ce09aee474316bc2e2c03"}, +] [package.dependencies] -aiohttp = {version = ">=3.8.1", optional = true} -click = {version = ">=8.1.2", optional = true} importlib-metadata = ">=4.11.3" -markdown-it-py = {version = "^2.1.0", extras = ["linkify", "plugins"]} -msgpack = {version = ">=1.0.3", optional = true} +markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]} rich = ">=13.3.3" -typing-extensions = "^4.4.0" +typing-extensions = ">=4.4.0,<5.0.0" -[package.extras] -dev = ["aiohttp (>=3.8.1)", "click (>=8.1.2)", "msgpack (>=1.0.3)"] +[[package]] +name = "textual-dev" +version = "1.0.1" +description = "Development tools for working with Textual" +category = "dev" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "textual_dev-1.0.1-py3-none-any.whl", hash = "sha256:419fc426c120f04f89ab0cb1aa88f7873dd7cdb9c21618e709175c8eaff6b566"}, + {file = "textual_dev-1.0.1.tar.gz", hash = "sha256:9f4c40655cbb56af7ee92805ef14fa24ae98ff8b0ae778c59de7222f1caa7281"}, +] -[package.source] -type = "directory" -url = "../textual" +[package.dependencies] +aiohttp = ">=3.8.1" +click = ">=8.1.2" +msgpack = ">=1.0.3" +textual = ">=0.29.0" +typing-extensions = ">=4.4.0,<5.0.0" [[package]] name = "tiktoken" @@ -1652,4 +1663,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "f2a1fe1ee0fed95267d5322a2ec3a223c56fa38e519612d36f733c785b759cef" +content-hash = "ad158f1220a6129853651d410f5e5304b89f2b9b1af71f494c73aa683d217c6a" diff --git a/pyproject.toml b/pyproject.toml index aa9955c..c2b71ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,21 +7,23 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.10" -#textual = {version = ">=0.18.0", extras = ["dev"]} openai = "^0.27.5" -textual = { path = "../textual", develop = true, extras = ["dev"] } +textual = "^0.30.0" +# textual = { path = "../textual", develop = true} sqlmodel = "^0.0.8" humanize = "^4.6.0" tiktoken = "^0.4.0" +click = "^8.1.6" [tool.poetry.scripts] -elia = "elia_chat.app:run" +elia = "elia_chat.__main__:cli" [tool.poetry.group.dev.dependencies] black = "^23.3.0" mypy = "^1.3.0" types-peewee = "^3.16.0.0" pre-commit = "^3.3.2" +textual-dev = "^1.0.1" [build-system] requires = ["poetry-core"]