Skip to content

Commit

Permalink
Initial Feature/chat management (#11)
Browse files Browse the repository at this point in the history
* reworking chat data flow

* added slash commands with tab complete to msg input for various things like focusing parts of screen, clearing, and saving.

* better scrolling on new messages

* better session handling

* fixed busy status

* added session save dialog

* updated deps

* added ability to copy selected chat message to clipboard

*added key combo to chat with selected model on local list

* updated readme and help
  • Loading branch information
paulrobello authored Jul 8, 2024
1 parent efaa531 commit 4ccb883
Show file tree
Hide file tree
Showing 21 changed files with 669 additions and 345 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@
/quantize_workspace/
/notes.md
/custom_models/
/chat.md
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
default_stages: [pre-commit, pre-push]
fail_fast: false
fail_fast: true
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.3.0
Expand Down
2 changes: 1 addition & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pydantic = "*"
ollama = "*"
bs4 = "*"
docker = "*"

textual-fspicker = "*"

[dev-packages]
mypy = "*"
Expand Down
420 changes: 173 additions & 247 deletions Pipfile.lock

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ docker pull ollama/quantize
If you don't have pipx installed you can run the following:
```bash
pip install pipx
pipx ensurepath
```
Once pipx is installed, run the following:
```bash
Expand Down Expand Up @@ -127,6 +128,10 @@ The command will look something like this:
```bash
parllama -u "http://$(hostname).local:11434"
```
Depending on your DNS setup if the above does not work, try this:
```bash
parllama -u "http://$(grep -m 1 nameserver /etc/resolv.conf | awk '{print $2}'):11434"
```

PAR_LLAMA will remember the -u flag so subsequent runs will not require that you specify it.

Expand Down Expand Up @@ -206,3 +211,11 @@ if anything remains to be fixed before the commit is allowed.
* Chat history / conversation management
* Chat with multiple models at same time to compare outputs
* LLM tool use


## What's new
### v0.2.5
* Added slash commands to chat input
* Added ability to export chat to markdown file
* ctrl+c on local model list will jump to chat tab and select currently selected local model
* ctrl+c on chat tab will copy selected chat message
2 changes: 1 addition & 1 deletion parllama/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
__credits__ = ["Paul Robello"]
__maintainer__ = "Paul Robello"
__email__ = "[email protected]"
__version__ = "0.2.4"
__version__ = "0.2.5"
__licence__ = "MIT"
__application_title__ = "PAR LLAMA"
__application_binary__ = "parllama"
Expand Down
25 changes: 17 additions & 8 deletions parllama/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from parllama.messages.main import LocalModelListRefreshRequested
from parllama.messages.main import ModelCreated
from parllama.messages.main import ModelCreateRequested
from parllama.messages.main import ModelInteractRequested
from parllama.messages.main import ModelPulled
from parllama.messages.main import ModelPullRequested
from parllama.messages.main import ModelPushed
Expand Down Expand Up @@ -146,7 +147,10 @@ async def on_mount(self) -> None:
"""Display the main or locked screen."""
await self.push_screen(self.main_screen)
self.main_screen.post_message(
StatusMessage(f"Data directory: {settings.data_dir}")
StatusMessage(f"Data folder: {settings.data_dir}")
)
self.main_screen.post_message(
StatusMessage(f"Chat folder: {settings.chat_dir}")
)
self.main_screen.post_message(
StatusMessage(f"Using Ollama server url: {settings.ollama_host}")
Expand Down Expand Up @@ -535,11 +539,9 @@ def on_site_models_refresh_requested(self, msg: SiteModelsRefreshRequested) -> N
self.refresh_site_models(msg)

@on(SiteModelsLoaded)
def on_site_models_loaded(self, msg: SiteModelsLoaded) -> None:
def on_site_models_loaded(self) -> None:
"""Site model refresh completed"""
self.status_notify(
f"Site models refreshed for {msg.ollama_namespace or 'models'}"
)
self.status_notify("Site models refreshed")

@work(group="refresh_site_model", thread=True)
async def refresh_site_models(self, msg: SiteModelsRefreshRequested):
Expand All @@ -566,7 +568,7 @@ async def refresh_site_models(self, msg: SiteModelsRefreshRequested):

@work(group="update_ps", thread=True)
async def update_ps(self) -> None:
"""Update ps msg"""
"""Update ps status bar msg"""
was_blank = False
while self.is_running:
await asyncio.sleep(2)
Expand All @@ -577,7 +579,7 @@ async def update_ps(self) -> None:
was_blank = True
continue
was_blank = False
info = ret[0]
info = ret[0] # only take first one since ps status bar is a single line
self.main_screen.post_message(
PsMessage(
msg=Text.assemble(
Expand All @@ -592,7 +594,6 @@ async def update_ps(self) -> None:
)
)
)
self.main_screen.post_message(StatusMessage(msg="exited..."))

def status_notify(self, msg: str, severity: SeverityLevel = "information") -> None:
"""Show notification and update status bar"""
Expand Down Expand Up @@ -628,3 +629,11 @@ def on_create_model_from_existing_requested(
self.main_screen.create_view.quantize_input.value = msg.quantization_level or ""
self.main_screen.change_tab("Create")
self.main_screen.create_view.name_input.focus()

@on(ModelInteractRequested)
def on_model_interact_requested(self, msg: ModelInteractRequested) -> None:
"""Model interact requested event"""
msg.stop()
self.main_screen.change_tab("Chat")
self.main_screen.chat_view.model_select.value = msg.model_name
self.main_screen.chat_view.user_input.focus()
21 changes: 20 additions & 1 deletion parllama/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ This screen displays all local models currently available to your local Ollama.
`ctrl+f` can be used to quickly focus the filter input box.
`ctrl+p` will pull the highlighted model.
`ctrl+u` will push the highlighted model to Ollama.com. Ensure you have your Ollama access key setup. See the **Publishing** section for details on how to do this.
`ctrl+c` will bring up the copy model dialog which will prompt you for the name to copy the model to.
`ctrl+d` will bring up the copy model dialog which will prompt you for the name to copy the model to.
`ctrl+c` will jump to chat tab and select the model highlighted model.

### Local Model Screen keys

Expand Down Expand Up @@ -73,6 +74,24 @@ You can use `ctrl+b` to open the model card in a web browser
## Model Tools Screen
This screen allows you to access tools to create, modify, and publish models

## Chat Screen
Chat with local LLM's

### Chat Screen keys

| Key | Command |
|----------|---------------------------------|
| `enter` | Send chat to LLM |
| `ctrl+c` | Copy selected chat to clipboard |

### Chat Slash Commands:
* /? or /help - Show slash command help dialog
* /clear - Clear the chat
* /model [model_name] - Select a model
* /temp [temperature] - Set the temperature
* /session [session_name] - Set the session name
* /save - Save the conversation to a file

## Logs Screen
This screen allows viewing any messages that have passed through the status bar.

Expand Down
9 changes: 8 additions & 1 deletion parllama/messages/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ class ChatMessageSent(Message):

@dataclass
class NewChatSession(Message):
"""Chat message sent class"""
"""New chat session class"""

id: str


@dataclass
class ModelInteractRequested(Message):
"""Message to notify that a model interact has been requested."""

model_name: str
124 changes: 108 additions & 16 deletions parllama/models/chat.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,46 @@
"""Chat manager class"""
from __future__ import annotations

import os
import uuid
from collections.abc import Iterator
from collections.abc import Mapping
from dataclasses import dataclass
from io import StringIO
from typing import Any
from typing import Literal

from ollama import Message as OMessage
from ollama import Options as OllamaOptions
from pydantic import BaseModel
from textual.widget import Widget

from parllama.messages.main import ChatMessage
from parllama.models.settings_data import settings


class OllamaMessage(OMessage):
"""Ollama message class"""
class OllamaMessage(BaseModel):
"""
Chat message.
"""

id: str
"Unique identifier of the message."

role: Literal["user", "assistant", "system"]
"Assumed role of the message. Response messages always has role 'assistant'."

content: str = ""
"Content of the message. Response messages contains message fragments when streaming."

def __str__(self) -> str:
"""Ollama message representation"""
return f"## {self.role}\n\n{self.content}\n\n"


def to_ollama_msg_native(data: OllamaMessage) -> OMessage:
"""Convert a message to Ollama native format"""
return OMessage(role=data.role, content=data.content)


@dataclass
Expand All @@ -29,6 +51,7 @@ class ChatSession:
llm_model_name: str
id: str
messages: list[OllamaMessage]
id_to_msg: dict[str, OllamaMessage]
options: OllamaOptions

def __init__(
Expand All @@ -41,42 +64,111 @@ def __init__(
"""Initialize the chat session"""
self.id = uuid.uuid4().hex
self.messages = []
self.id_to_msg = {}
self.session_name = session_name
self.llm_model_name = llm_model_name
self.options = options or {}

def get_message(self, message_id: str) -> OllamaMessage | None:
"""Get a message"""
for message in self.messages:
if message["id"] == message_id:
return message
if message_id in self.id_to_msg:
return self.id_to_msg[message_id]
return None

def push_message(self, message: OllamaMessage) -> None:
"""Push a message"""
self.messages.append(message)

async def send_chat(self, from_user: str, widget: Widget) -> bool:
"""Send a chat message to LLM"""
msg_id = uuid.uuid4().hex
self.messages.append(OllamaMessage(id=msg_id, content=from_user, role="user"))
msg: OllamaMessage = OllamaMessage(id=msg_id, content=from_user, role="user")
self.messages.append(msg)
self.id_to_msg[msg.id] = msg
widget.post_message(ChatMessage(session_id=self.id, message_id=msg_id))

msg_id = uuid.uuid4().hex
msg = OllamaMessage(id=msg_id, content="", role="assistant")
self.messages.append(msg)
self.id_to_msg[msg.id] = msg
widget.post_message(ChatMessage(session_id=self.id, message_id=msg_id))

stream: Iterator[Mapping[str, Any]] = settings.ollama_client.chat( # type: ignore
model=self.llm_model_name,
messages=self.messages,
messages=[to_ollama_msg_native(m) for m in self.messages],
options=self.options,
stream=True,
)
msg_id = uuid.uuid4().hex
msg: OllamaMessage = OllamaMessage(id=msg_id, content="", role="assistant")
self.messages.append(msg)

for chunk in stream:
msg["content"] += chunk["message"]["content"]
msg.content += chunk["message"]["content"]
widget.post_message(ChatMessage(session_id=self.id, message_id=msg_id))

return True

def new_session(self):
def new_session(self, session_name: str = "My Chat"):
"""Start new session"""
self.id = uuid.uuid4().hex
self.session_name = session_name
self.messages.clear()
self.id_to_msg.clear()

def __iter__(self):
"""Iterate over messages"""
return iter(self.messages)

def __len__(self) -> int:
"""Get the number of messages"""
return len(self.messages)

def __getitem__(self, msg_id: str) -> OllamaMessage:
"""Get a message"""
return self.id_to_msg[msg_id]

def __setitem__(self, msg_id: str, value: OllamaMessage) -> None:
"""Set a message"""
self.id_to_msg[msg_id] = value
for i, msg in enumerate(self.messages):
if msg.id == msg_id:
self.messages[i] = value
return
self.messages.append(value)

def __delitem__(self, key: str) -> None:
"""Delete a message"""
del self.id_to_msg[key]
for msg in self.messages:
if msg.id == key:
self.messages.remove(msg)
return

def __contains__(self, item: OllamaMessage) -> bool:
"""Check if a message exists"""
return item.id in self.id_to_msg

def __eq__(self, other: object) -> bool:
"""Check if two sessions are equal"""
if not isinstance(other, ChatSession):
return NotImplemented
return self.id == other.id

def __ne__(self, other: object) -> bool:
"""Check if two sessions are not equal"""
if not isinstance(other, ChatSession):
return NotImplemented
return self.id != other.id

def __str__(self) -> str:
"""Get a string representation of the chat session"""
ret = StringIO()
ret.write(f"# {self.session_name}\n\n")
for msg in self.messages:
ret.write(str(msg))
return ret.getvalue()

def save(self, filename: str) -> bool:
"""Save the chat session to a file"""
try:
with open(
os.path.join(settings.chat_dir, filename), "w", encoding="utf-8"
) as f:
f.write(str(self))
return True
except (OSError, IOError):
return False
Loading

0 comments on commit 4ccb883

Please sign in to comment.