Skip to content

Commit 9c53aa0

Browse files
committed
fix: fix conversation list order in picker, lazily load conversation metadata, add get_user_conversations(), add ?limit=<int> to /api/conversations and use it in webui
1 parent e1b881a commit 9c53aa0

File tree

9 files changed

+185
-63
lines changed

9 files changed

+185
-63
lines changed

gptme/cli.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import urllib.parse
1010
from collections.abc import Generator
1111
from datetime import datetime
12+
from itertools import islice
1213
from pathlib import Path
1314
from typing import Literal
1415

@@ -28,7 +29,7 @@
2829
from .dirs import get_logs_dir
2930
from .init import init, init_logging
3031
from .llm import reply
31-
from .logmanager import LogManager, _conversations
32+
from .logmanager import Conversation, LogManager, get_user_conversations
3233
from .message import Message
3334
from .models import get_model
3435
from .prompts import get_prompt
@@ -407,16 +408,24 @@ def get_name(name: str) -> Path:
407408
return logpath
408409

409410

410-
def get_logfile(name: str | Literal["random", "resume"], interactive=True) -> Path:
411+
def get_logfile(
412+
name: str | Literal["random", "resume"], interactive=True, limit=20
413+
) -> Path:
411414
# let user select between starting a new conversation and loading a previous one
412415
# using the library
413416
title = "New conversation or load previous? "
414417
NEW_CONV = "New conversation"
415-
prev_conv_files = list(reversed(_conversations()))
418+
LOAD_MORE = "Load more"
419+
gen_convs = get_user_conversations()
420+
convs: list[Conversation] = []
421+
try:
422+
convs.append(next(gen_convs))
423+
except StopIteration:
424+
pass
416425

417426
if name == "resume":
418-
if prev_conv_files:
419-
return prev_conv_files[0].parent / "conversation.jsonl"
427+
if convs:
428+
return Path(convs[0].path)
420429
else:
421430
raise ValueError("No previous conversations to resume")
422431

@@ -426,24 +435,32 @@ def get_logfile(name: str | Literal["random", "resume"], interactive=True) -> Pa
426435
# return "-test-" in name or name.startswith("test-")
427436
# prev_conv_files = [f for f in prev_conv_files if not is_test(f.parent.name)]
428437

429-
NEWLINE = "\n"
438+
# load more conversations
439+
convs.extend(islice(gen_convs, limit - 1))
440+
430441
prev_convs = [
431-
f"{f.parent.name:30s} \t{epoch_to_age(f.stat().st_mtime)} \t{len(f.read_text().split(NEWLINE)):5d} msgs"
432-
for f in prev_conv_files
442+
f"{conv.name:30s} \t{epoch_to_age(conv.modified)} \t{conv.messages:5d} msgs"
443+
for conv in convs
433444
]
434445

435446
# don't run pick in tests/non-interactive mode, or if the user specifies a name
436447
if interactive and name in ["random"]:
437-
options = [
438-
NEW_CONV,
439-
] + prev_convs
448+
options = (
449+
[
450+
NEW_CONV,
451+
]
452+
+ prev_convs
453+
+ [LOAD_MORE]
454+
)
440455

441456
index: int
442457
_, index = pick(options, title) # type: ignore
443458
if index == 0:
444459
logdir = get_name(name)
460+
elif index == len(options) - 1:
461+
return get_logfile(name, interactive, limit + 100)
445462
else:
446-
logdir = get_logs_dir() / prev_conv_files[index - 1].parent
463+
logdir = get_logs_dir() / convs[index - 1].name
447464
else:
448465
logdir = get_name(name)
449466

gptme/logmanager.py

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import textwrap
55
from collections.abc import Generator
66
from copy import copy
7+
from dataclasses import dataclass
78
from datetime import datetime
8-
from itertools import zip_longest
9+
from itertools import islice, zip_longest
910
from pathlib import Path
1011
from tempfile import TemporaryDirectory
1112
from typing import Any, Literal, TypeAlias
@@ -288,40 +289,68 @@ def to_dict(self, branches=False) -> dict:
288289
return d
289290

290291

291-
def _conversations() -> list[Path]:
292+
def _conversation_files() -> list[Path]:
292293
# NOTE: only returns the main conversation, not branches (to avoid duplicates)
293-
# returns the most recent first
294+
# returns the conversation files sorted by modified time (newest first)
294295
logsdir = get_logs_dir()
295296
return list(
296297
sorted(logsdir.glob("*/conversation.jsonl"), key=lambda f: -f.stat().st_mtime)
297298
)
298299

299300

300-
def get_conversations() -> Generator[dict, None, None]:
301-
for conv_fn in _conversations():
302-
msgs = []
303-
msgs = _read_jsonl(conv_fn)
304-
modified = conv_fn.stat().st_mtime
305-
first_timestamp = msgs[0].timestamp.timestamp() if msgs else modified
306-
yield {
307-
"name": f"{conv_fn.parent.name}",
308-
"path": str(conv_fn),
309-
"created": first_timestamp,
310-
"modified": modified,
311-
"messages": len(msgs),
312-
"branches": 1 + len(list(conv_fn.parent.glob("branches/*.jsonl"))),
313-
}
301+
@dataclass
302+
class Conversation:
303+
name: str
304+
path: str
305+
created: float
306+
modified: float
307+
messages: int
308+
branches: int
314309

315310

316-
def _read_jsonl(path: PathLike) -> list[Message]:
317-
msgs = []
311+
def get_conversations() -> Generator[Conversation, None, None]:
312+
"""Returns all conversations, excluding ones used for testing, evals, etc."""
313+
for conv_fn in _conversation_files():
314+
msgs = _read_jsonl(conv_fn, limit=1)
315+
# TODO: can we avoid reading the entire file? maybe wont even be used, due to user convo filtering
316+
len_msgs = conv_fn.read_text().count("}\n{")
317+
assert len(msgs) <= 1
318+
modified = conv_fn.stat().st_mtime
319+
first_timestamp = msgs[0].timestamp.timestamp() if msgs else modified
320+
yield Conversation(
321+
name=f"{conv_fn.parent.name}",
322+
path=str(conv_fn),
323+
created=first_timestamp,
324+
modified=modified,
325+
messages=len_msgs,
326+
branches=1 + len(list(conv_fn.parent.glob("branches/*.jsonl"))),
327+
)
328+
329+
330+
def get_user_conversations() -> Generator[Conversation, None, None]:
331+
"""Returns all user conversations, excluding ones used for testing, evals, etc."""
332+
for conv in get_conversations():
333+
if any(conv.name.startswith(prefix) for prefix in ["tmp", "test-"]) or any(
334+
substr in conv.name for substr in ["gptme-evals-"]
335+
):
336+
continue
337+
yield conv
338+
339+
340+
def _gen_read_jsonl(path: PathLike) -> Generator[Message, None, None]:
318341
with open(path) as file:
319342
for line in file.readlines():
320343
json_data = json.loads(line)
321344
if "timestamp" in json_data:
322345
json_data["timestamp"] = datetime.fromisoformat(json_data["timestamp"])
323-
msgs.append(Message(**json_data))
324-
return msgs
346+
yield Message(**json_data)
347+
348+
349+
def _read_jsonl(path: PathLike, limit=None) -> list[Message]:
350+
gen = _gen_read_jsonl(path)
351+
if limit:
352+
gen = islice(gen, limit) # type: ignore
353+
return list(gen)
325354

326355

327356
def _write_jsonl(path: PathLike, msgs: list[Message]) -> None:

gptme/message.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424

2525
logger = logging.getLogger(__name__)
2626

27+
# max tokens allowed in a single system message
28+
# if you hit this limit, you and/or I f-ed up, and should make the message shorter
29+
# maybe we should make it possible to store long outputs in files, and link/summarize it/preview it in the message
30+
max_system_len = 20000
31+
2732

2833
@dataclass(frozen=True, eq=False)
2934
class Message:
@@ -51,6 +56,9 @@ class Message:
5156

5257
def __post_init__(self):
5358
assert isinstance(self.timestamp, datetime)
59+
if self.role == "system":
60+
if (length := len_tokens(self)) >= max_system_len:
61+
logger.warning(f"System message too long: {length} tokens")
5462

5563
def __repr__(self):
5664
content = textwrap.shorten(self.content, 20, placeholder="...")

gptme/server/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Server for gptme.
33
"""
44

5-
from .api import create_app, main
5+
from .api import create_app
6+
from .cli import main
67

78
__all__ = ["main", "create_app"]

gptme/server/api.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
from contextlib import redirect_stdout
1111
from datetime import datetime
1212
from importlib import resources
13+
from itertools import islice
1314

1415
import flask
15-
from flask import current_app
16+
from flask import current_app, request
1617

1718
from ..commands import execute_cmd
1819
from ..dirs import get_logs_dir
1920
from ..llm import reply
20-
from ..logmanager import LogManager, get_conversations
21+
from ..logmanager import LogManager, get_user_conversations
2122
from ..message import Message
2223
from ..models import get_model
2324
from ..tools import execute_msg
@@ -32,7 +33,8 @@ def api_root():
3233

3334
@api.route("/api/conversations")
3435
def api_conversations():
35-
conversations = list(get_conversations())
36+
limit = int(request.args.get("limit", 100))
37+
conversations = list(islice(get_user_conversations(), limit))
3638
return flask.jsonify(conversations)
3739

3840

@@ -149,9 +151,3 @@ def create_app() -> flask.Flask:
149151
app = flask.Flask(__name__, static_folder=static_path)
150152
app.register_blueprint(api)
151153
return app
152-
153-
154-
def main() -> None:
155-
"""Run the Flask app."""
156-
app = create_app()
157-
app.run(debug=True)

gptme/server/cli.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,20 @@
33
import click
44

55
from ..init import init, init_logging
6+
from .api import create_app
67

78
logger = logging.getLogger(__name__)
89

910

1011
@click.command("gptme-server")
11-
@click.option("-v", "--verbose", is_flag=True, help="Verbose output.")
12+
@click.option("--debug", is_flag=True, help="Debug mode")
13+
@click.option("-v", "--verbose", is_flag=True, help="Verbose output")
1214
@click.option(
1315
"--model",
1416
default=None,
1517
help="Model to use by default, can be overridden in each request.",
1618
)
17-
def main(verbose: bool, model: str | None): # pragma: no cover
19+
def main(debug: bool, verbose: bool, model: str | None): # pragma: no cover
1820
"""
1921
Starts a server and web UI for gptme.
2022
@@ -34,7 +36,5 @@ def main(verbose: bool, model: str | None): # pragma: no cover
3436
exit(1)
3537
click.echo("Initialization complete, starting server")
3638

37-
# noreorder
38-
from gptme.server.api import main as server_main # fmt: skip
39-
40-
server_main()
39+
app = create_app()
40+
app.run(debug=debug)

gptme/tools/chats.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
import logging
77
from pathlib import Path
88
from textwrap import indent
9+
from typing import TYPE_CHECKING
910

1011
from ..message import Message
1112
from .base import ToolSpec
1213

14+
if TYPE_CHECKING:
15+
from ..logmanager import LogManager
16+
1317
logger = logging.getLogger(__name__)
1418

1519

@@ -33,7 +37,9 @@ def _get_matching_messages(log_manager, query: str, system=False) -> list[Messag
3337
]
3438

3539

36-
def _summarize_conversation(log_manager, include_summary: bool) -> list[str]:
40+
def _summarize_conversation(
41+
log_manager: "LogManager", include_summary: bool
42+
) -> list[str]:
3743
"""Summarize a conversation."""
3844
# noreorder
3945
from ..llm import summarize as llm_summarize # fmt: skip
@@ -80,11 +86,10 @@ def list_chats(max_results: int = 5, include_summary: bool = False) -> None:
8086

8187
print(f"Recent conversations (showing up to {max_results}):")
8288
for i, conv in enumerate(conversations, 1):
83-
print(f"\n{i}. {conv['name']}")
84-
if "created_at" in conv:
85-
print(f" Created: {conv['created_at']}")
89+
print(f"\n{i}. {conv.name}")
90+
print(f" Created: {conv.created}")
8691

87-
log_path = Path(conv["path"])
92+
log_path = Path(conv.path)
8893
log_manager = LogManager.load(log_path)
8994

9095
summary_lines = _summarize_conversation(log_manager, include_summary)
@@ -101,19 +106,19 @@ def search_chats(query: str, max_results: int = 5, system=False) -> None:
101106
system (bool): Whether to include system messages in the search.
102107
"""
103108
# noreorder
104-
from ..logmanager import LogManager, get_conversations # fmt: skip
109+
from ..logmanager import LogManager, get_user_conversations # fmt: skip
105110

106-
results = []
107-
for conv in get_conversations():
108-
log_path = Path(conv["path"])
111+
results: list[dict] = []
112+
for conv in get_user_conversations():
113+
log_path = Path(conv.path)
109114
log_manager = LogManager.load(log_path)
110115

111116
matching_messages = _get_matching_messages(log_manager, query, system)
112117

113118
if matching_messages:
114119
results.append(
115120
{
116-
"conversation": conv["name"],
121+
"conversation": conv.name,
117122
"log_manager": log_manager,
118123
"matching_messages": matching_messages,
119124
}
@@ -165,8 +170,8 @@ def read_chat(conversation: str, max_results: int = 5, incl_system=False) -> Non
165170
conversations = list(get_conversations())
166171

167172
for conv in conversations:
168-
if conv["name"] == conversation:
169-
log_path = Path(conv["path"])
173+
if conv.name == conversation:
174+
log_path = Path(conv.path)
170175
logmanager = LogManager.load(log_path)
171176
print(f"Reading conversation: {conversation}")
172177
i = 0

0 commit comments

Comments
 (0)