Skip to content

Commit 47df349

Browse files
committed
Initial add
1 parent 638f828 commit 47df349

15 files changed

+758
-1
lines changed

LICENSE

+1-1
Original file line numberDiff line numberDiff line change
@@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you
671671
may consider it more useful to permit linking proprietary applications with
672672
the library. If this is what you want to do, use the GNU Lesser General
673673
Public License instead of this License. But first, please read
674-
<https://www.gnu.org/licenses/why-not-lgpl.html>.
674+
<https://www.gnu.org/licenses/why-not-lgpl.html>.

Makefile

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.PHONY: test upload
2+
3+
test:
4+
rm -rf testenv/ ; python3 -m venv testenv ; source testenv/bin/activate.csh ; pip install -e . ; rehash ; gptline
5+
6+
upload:
7+
rm -f dist/*
8+
python3 setup.py bdist_wheel
9+
python3 setup.py sdist
10+
twine upload dist/*

README.md

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# gptline
2+
3+
gptline is a command-line ChatGPT client written in Python. It allows you to interact with OpenAI's GPT models using a terminal emulator. With gptline, you can have multi-line conversations with a useful and unintrusive UI.
4+
5+
## Features
6+
7+
- Multi-line input: gptline allows you to input multiple lines of text as prompts, making it easier to have more complex conversations with the chatgpt model.
8+
- Multiple conversations: You can have multiple ongoing conversations with the chatgpt model, allowing you to switch between different contexts seamlessly.
9+
- Search past messages: gptline keeps track of past messages, making it easy to search and reference previous interactions.
10+
- Response regeneration: If you're not satisfied with the initial response, gptline allows you to regenerate a new response based on the same prompt.
11+
- Editing past prompts: You can edit past prompts to refine or change the context of the conversation.
12+
- iTerm2 support: It adds marks so you can easily navigate from message to message.
13+
14+
## Installation
15+
16+
You can install gptline using pip3:
17+
18+
```bash
19+
pip3 install gptline
20+
```
21+
22+
## Usage
23+
24+
To start using gptline, simply run the following command:
25+
26+
```bash
27+
gptline
28+
```
29+
30+
Once you're in the gptline terminal, you can start interacting with the chatgpt model by entering your prompts. Use the `Ctrl+C` shortcut to exit the gptline terminal.
31+
32+
## License
33+
34+
gptline is released under the GPL v3 license. See [LICENSE](LICENSE) for more information.
35+
36+
## Contributing
37+
38+
Contributions to gptline are welcome! If you find any issues or have suggestions for improvements, please open an issue or submit a pull request.
39+

gptline.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/usr/bin/env python3
2+
3+
from src import main
4+
5+
if __name__ == "__main__":
6+
main()

requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
openai
2+
prompt-toolkit

setup.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from setuptools import setup
2+
3+
with open("requirements.txt") as f:
4+
requirements = f.read().splitlines()
5+
6+
setup(
7+
name="gptline",
8+
version="1.0.0",
9+
packages=["src"],
10+
install_requires=requirements,
11+
entry_points={
12+
"console_scripts": [
13+
"gptline = src.main:main"
14+
]
15+
},
16+
)

src/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Empty

src/background_task.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import threading
2+
3+
class BackgroundTask:
4+
def __init__(self, func):
5+
self._thread = threading.Thread(target=self._run, args=(func,))
6+
self._result = None
7+
self._done = False
8+
self._thread.start()
9+
10+
def _run(self, func):
11+
self._result = func()
12+
self._done = True
13+
14+
def done(self):
15+
return self._done
16+
17+
def result(self):
18+
return self._result
19+
20+

src/chat.py

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import sys
2+
import time
3+
import openai
4+
import threading
5+
6+
def create_chat_with_spinner(messages, temperature):
7+
chats = []
8+
9+
def show_spinner():
10+
spinner = ["|", "/", "-", "\\"]
11+
i = 0
12+
while len(chats) == 0:
13+
sys.stdout.write("\r" + spinner[i % 4])
14+
sys.stdout.flush()
15+
time.sleep(0.1)
16+
i += 1
17+
18+
19+
def create_chat_model():
20+
chat = openai.ChatCompletion.create(
21+
model="gpt-3.5-turbo", messages=messages, stream=True, temperature=temperature
22+
)
23+
chats.append(chat)
24+
25+
thread = threading.Thread(target=create_chat_model)
26+
thread.start()
27+
28+
spinner_thread = threading.Thread(target=show_spinner)
29+
spinner_thread.start()
30+
31+
thread.join()
32+
spinner_thread.join()
33+
34+
sys.stdout.write("\r \r")
35+
return chats[0]
36+
37+
38+
def suggest_name(chat_id, message):
39+
chat_completion_resp = openai.ChatCompletion.create(
40+
model="gpt-3.5-turbo",
41+
messages=[
42+
{"role": "system", "content": "You assign names to conversations based on the first message. Respond with only a short, descriptive title for a conversation."},
43+
{"role": "user", "content": message}
44+
],
45+
temperature=0,
46+
max_tokens=10
47+
)
48+
name = chat_completion_resp.choices[0].message.content
49+
return (chat_id, name)
50+

src/db.py

+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import sqlite3
2+
import fcntl
3+
import os
4+
import sys
5+
6+
def fullpath(file):
7+
xdg_root = os.getenv("XDG_ROOT")
8+
if xdg_root:
9+
return os.path.join(xdg_root, file)
10+
else:
11+
home_dir = os.path.expanduser("~")
12+
return os.path.join(home_dir, file)
13+
14+
lock_fd = None
15+
16+
def check_single_instance(lock_file):
17+
global lock_fd
18+
19+
try:
20+
lock_fd = os.open(lock_file, os.O_CREAT | os.O_TRUNC | os.O_EXLOCK | os.O_NONBLOCK)
21+
except BlockingIOError:
22+
# Another instance is already running, so terminate
23+
print("Another instance is already running. Exiting.")
24+
sys.exit(1)
25+
26+
class ChatDB:
27+
def __init__(self, db_file=".chatgpt.db"):
28+
db_path = fullpath(db_file)
29+
check_single_instance(db_path + ".lock")
30+
self.conn = sqlite3.connect(db_path)
31+
self.create_schema()
32+
33+
def create_schema(self):
34+
query = """
35+
CREATE TABLE IF NOT EXISTS chats (
36+
id INTEGER PRIMARY KEY AUTOINCREMENT,
37+
name TEXT,
38+
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP
39+
)
40+
"""
41+
self.conn.execute(query)
42+
43+
query = """
44+
CREATE TABLE IF NOT EXISTS messages (
45+
id INTEGER PRIMARY KEY AUTOINCREMENT,
46+
chat_id INTEGER,
47+
role TEXT,
48+
content TEXT,
49+
time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
50+
deleted INTEGER DEFAULT 0,
51+
FOREIGN KEY (chat_id) REFERENCES chats (id)
52+
)
53+
"""
54+
self.conn.execute(query)
55+
56+
query = """
57+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING FTS5 (
58+
message_id UNINDEXED,
59+
content,
60+
content_rowid,
61+
)
62+
"""
63+
self.conn.execute(query)
64+
65+
def create_chat(self, name=None):
66+
cursor = self.conn.cursor() # Create a cursor
67+
query = "INSERT INTO chats (name) VALUES (?)"
68+
cursor.execute(query, (name,))
69+
chat_id = cursor.lastrowid # Access lastrowid from the cursor
70+
self.conn.commit()
71+
return chat_id
72+
73+
def add_message(self, chat_id: int, role: str, content: str):
74+
cursor = self.conn.cursor() # Create a cursor
75+
query = "INSERT INTO messages (chat_id, role, content) VALUES (?, ?, ?)"
76+
cursor.execute(query, (chat_id, role, content))
77+
self.conn.commit()
78+
last_message_id = cursor.lastrowid
79+
80+
fts_query = "INSERT INTO messages_fts (message_id, content) VALUES (?, ?)"
81+
self.conn.execute(fts_query, (last_message_id, content.lower()))
82+
self.conn.commit()
83+
84+
query = f"UPDATE chats SET last_update = CURRENT_TIMESTAMP WHERE id = ?"
85+
cursor.execute(query, (chat_id, ))
86+
87+
return last_message_id
88+
89+
def num_messages(self, chat_id: int) -> int:
90+
query = "SELECT COUNT(*) FROM messages WHERE chat_id = ?"
91+
result = self.conn.execute(query, (chat_id,)).fetchone()
92+
return result[0]
93+
94+
def get_message_by_id(self, message_id: int):
95+
query = "SELECT role, content, time, id, deleted FROM messages WHERE id = ?"
96+
result = self.conn.execute(query, (message_id,)).fetchone()
97+
if result:
98+
return result
99+
else:
100+
raise IndexError("Index out of range")
101+
102+
def get_message_by_index(self, chat_id: int, index: int):
103+
query = "SELECT role, content, time, id, deleted FROM messages WHERE chat_id = ? ORDER BY id LIMIT 1 OFFSET ?"
104+
result = self.conn.execute(query, (chat_id, index)).fetchone()
105+
if result:
106+
return result
107+
else:
108+
raise IndexError("Index out of range")
109+
110+
def list_chats(self):
111+
query = "SELECT id, name, last_update FROM chats ORDER BY id DESC"
112+
result = self.conn.execute(query).fetchall()
113+
return result
114+
115+
def set_chat_name(self, chat_id: int, name: str):
116+
query = "UPDATE chats SET name = ? WHERE id = ?"
117+
self.conn.execute(query, (name, chat_id))
118+
self.conn.commit()
119+
120+
def get_chat_name(self, chat_id):
121+
query = "SELECT name FROM chats WHERE id = ?"
122+
cursor = self.conn.execute(query, (chat_id,))
123+
result = cursor.fetchone()
124+
if result:
125+
return result[0]
126+
else:
127+
return None
128+
129+
def delete_message(self, message_id: int):
130+
query = """
131+
UPDATE messages
132+
SET deleted = 1
133+
WHERE id = ?
134+
"""
135+
self.conn.execute(query, (message_id,))
136+
self.conn.commit()
137+
138+
def search_messages(self, query: str, pagination_token: int, limit: int):
139+
fts_query = """
140+
SELECT m.id, m.chat_id, snippet(messages_fts, 1, '\ue000', '\ue001', '...', 16) AS snippet
141+
FROM messages_fts
142+
JOIN messages m ON messages_fts.message_id = m.id
143+
WHERE messages_fts.content MATCH ?
144+
LIMIT ?
145+
OFFSET ?
146+
"""
147+
148+
if pagination_token is None:
149+
offset = 0
150+
else:
151+
offset = pagination_token
152+
153+
parameters = (query.lower(), limit, offset)
154+
155+
result = self.conn.execute(fts_query, parameters)
156+
157+
message_ids = []
158+
chat_ids = []
159+
snippets = {}
160+
for row in result:
161+
message_ids.append(int(row[0]))
162+
snippets[int(row[0])] = row[2]
163+
chat_ids.append(int(row[1]))
164+
165+
# Sort chat IDs
166+
sorted_chat_ids = sorted(set(chat_ids))
167+
168+
messages_by_chat = []
169+
for chat_id in sorted_chat_ids:
170+
message_ids_for_chat = [(message_ids[i], snippets[message_ids[i]]) for i in range(len(message_ids)) if chat_ids[i] == chat_id]
171+
messages_by_chat.append((chat_id, message_ids_for_chat))
172+
173+
# Determine the pagination token
174+
next_offset = offset + limit
175+
has_more_results = len(message_ids) > next_offset
176+
pagination_token = next_offset if has_more_results else None
177+
178+
return messages_by_chat, offset + len(message_ids)
179+

src/formatting.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import datetime
2+
from prompt_toolkit import print_formatted_text
3+
from prompt_toolkit.formatted_text import HTML
4+
import os
5+
6+
def formattedTime(timestamp):
7+
dt = datetime.datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S")
8+
dt = dt.replace(tzinfo=datetime.timezone.utc)
9+
dt = dt.astimezone()
10+
return dt.strftime("%b %d, %Y at %I:%M %p")
11+
12+
def print_message(timestamp, role, content, deleted, prefix=""):
13+
s = prefix + f"[{formattedTime(timestamp)}] <i>{role}</i>: {content}"
14+
if deleted:
15+
print_formatted_text(HTML("<strike>" + s + "</strike>"))
16+
else:
17+
print_formatted_text(HTML(s))
18+
19+
20+
def setMark():
21+
if 'TERM' in os.environ and os.environ['TERM'].startswith('screen'):
22+
osc = "\033Ptmux;\033\033]"
23+
st = "\a\033\\"
24+
else:
25+
osc = "\033]"
26+
st = "\a"
27+
28+
print(f"{osc}1337;SetMark{st}", end='')
29+

0 commit comments

Comments
 (0)