Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/graphite-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Graphite CI

on:
push:
branches:
- main
- fix/**
pull_request:

jobs:
regression-tests:
runs-on: ubuntu-latest
env:
QT_QPA_PLATFORM: offscreen
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Install Qt system libraries
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libgl1 libopengl0 libxkbcommon-x11-0 libxcb-cursor0
- name: Install dependencies
run: python -m pip install -r requirements.txt
- name: Compile modular application
run: python -m compileall -q graphite_app/graphite_app
- name: Run regression tests
run: python -m unittest discover -s tests -v
54 changes: 24 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,58 +132,52 @@ Follow these steps to run Graphite locally.

### 1. Prerequisites

- **Python:** Python 3.8 or newer
- **Python:** Python 3.10 or newer
- **Ollama:** Installed and running — https://ollama.com

### 2. Install an LLM Model

Pull the default model used by the application:

```bash
ollama pull qwen2.5:7b-instruct
````

Additional models can be configured inside the application using the model selection dialog.

### 3. Clone and Install Dependencies

Clone the repository:
### 2. Clone and Install Dependencies

```bash
git clone https://github.com/dovvnloading/Graphite.git
cd Graphite
python -m venv venv
```

Create a virtual environment (recommended):
Activate the environment with `venv\\Scripts\\activate` on Windows or
`source venv/bin/activate` on macOS/Linux, then install the modular PySide6 app:

```bash
# Windows
python -m venv venv
.\venv\Scripts\activate

# macOS/Linux
python3 -m venv venv
source venv/bin/activate
python -m pip install -r requirements.txt
```

Install dependencies:
### 3. Install the Default Ollama Models

Install and start [Ollama](https://ollama.com), then pull all three default models
configured in `graphite_app/graphite_app/graphite_config.py`:

```bash
pip install -r requirements.txt
ollama pull qwen2.5:3b
ollama pull qwen2.5:7b-instruct
ollama pull deepseek-coder:6.7b
```

If no `requirements.txt` is available:
These are used for session titles, chat, and charts respectively. Graphite checks
for missing local models at startup and shows the required pull commands.
To use a remote API instead, select **API** mode and configure its provider and
models in Settings.

```bash
pip install PySide6 ollama matplotlib qtawesome
```
### 4. Run the Modular Application

### 4. Run the Application
From the repository root:

```bash
python graphite_app.py
python graphite_app/graphite_app/graphite_app.py
```

The top-level `Graphite.py` is an archived PyQt5 version, not the modular
application. To run that older version instead, install
`requirements-legacy.txt` and execute `python Graphite.py`.

## Usage

* **Sending Messages:** Enter a prompt in the input box and press Enter to create a new node followed by the AI response.
Expand Down
15 changes: 15 additions & 0 deletions graphite_app/graphite_app/api_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,21 @@ def get_available_models():
except Exception as e:
raise RuntimeError(f"Failed to fetch models from endpoint: {str(e)}")

def get_missing_ollama_models():
"""Check all configured local task models without triggering downloads."""
response = ollama.Client(timeout=2.0).list()
installed = response.get('models', []) if isinstance(response, dict) else response.models
available = set()
for item in installed:
if isinstance(item, dict):
name = item.get('model') or item.get('name')
else:
name = getattr(item, 'model', None) or getattr(item, 'name', None)
if name:
available.add(name)
return sorted(set(config.OLLAMA_MODELS.values()) - available)


def set_mode(use_api: bool):
global USE_API_MODE
USE_API_MODE = use_api
Expand Down
45 changes: 21 additions & 24 deletions graphite_app/graphite_app/graphite_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,44 +18,41 @@ def __init__(self, agent, message, conversation_history):
def run(self):
try:
# Use agent's get_response directly
response = self.agent.get_response(self.message)
response = self.agent.get_response(self.message, self.conversation_history)
self.finished.emit(response)
except Exception as e:
self.error.emit(str(e))

class ChatWorker:
"""Compose chat messages and call the configured provider synchronously."""
"""Compose chat messages for one branch and call the configured provider."""
def __init__(self, system_prompt, conversation_history):
self.system_prompt = system_prompt
self.conversation_history = conversation_history
self.conversation_history = list(conversation_history)

def run(self, user_message):
try:
messages = [
{'role': 'system', 'content': self.system_prompt},
*self.conversation_history,
{'role': 'user', 'content': user_message}
]
response = api_provider.chat(task=config.TASK_CHAT, messages=messages)
ai_message = response['message']['content']
return ai_message
except Exception as e:
return f"Error: {str(e)}"
# A node's history already ends with the new user message. Do not
# append it twice; also support callers that provide ancestor history.
messages = [
{'role': 'system', 'content': self.system_prompt},
*self.conversation_history
]
user_turn = {'role': 'user', 'content': user_message}
if not self.conversation_history or self.conversation_history[-1] != user_turn:
messages.append(user_turn)
response = api_provider.chat(task=config.TASK_CHAT, messages=messages)
return response['message']['content']


class ChatAgent:
"""Stateful assistant that tracks conversation history per node/session."""
"""Stateless assistant: all context comes from the requested graph branch."""
def __init__(self, name, persona):
self.name = name or "AI Assistant"
self.persona = persona or "(default persona)"
self.system_prompt = f"You are {self.name}. {self.persona}"
self.conversation_history = []

def get_response(self, user_message):
chat_worker = ChatWorker(self.system_prompt, self.conversation_history)
ai_response = chat_worker.run(user_message)
self.conversation_history.append({'role': 'user', 'content': user_message})
self.conversation_history.append({'role': 'assistant', 'content': ai_response})
return ai_response

def get_response(self, user_message, conversation_history=None):
worker = ChatWorker(self.system_prompt, conversation_history or [])
return worker.run(user_message)

class ExplainerAgent:
"""Generate plain-language explanations and normalize the output format."""
Expand Down
103 changes: 84 additions & 19 deletions graphite_app/graphite_app/graphite_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QToolBar,
QToolButton, QLineEdit, QPushButton, QMessageBox, QSizePolicy, QLabel, QComboBox
)
from PySide6.QtCore import Qt, QSize, QPointF
from PySide6.QtCore import Qt, QSize, QPointF, QTimer
from PySide6.QtGui import QKeySequence, QGuiApplication, QCursor, QShortcut
import qtawesome as qta
import json
Expand Down Expand Up @@ -215,9 +215,46 @@ def keyPressEvent(self, event):
super().keyPressEvent(event)

def save_chat(self):
"""Save the current chat session"""
self.session_manager.save_current_chat()
QMessageBox.information(self, "Success", "Chat saved successfully!")
"""Save the session and report the actual result."""
try:
saved = self.session_manager.save_current_chat()
except Exception as exc:
QMessageBox.critical(self, "Save Failed", str(exc))
return
if saved:
QMessageBox.information(self, "Success", "Chat saved successfully!")
else:
QMessageBox.information(self, "Nothing to Save", "The canvas is empty.")

def confirm_session_transition(self):
"""Protect unsaved work before loading, clearing, or closing a session."""
for worker_name in ('chat_thread', 'takeaway_thread', 'explainer_thread', 'chart_thread'):
worker = getattr(self, worker_name, None)
if worker is not None and worker.isRunning():
QMessageBox.warning(
self, "Generation in Progress",
"Finish the current AI task before switching or closing chats."
)
return False
if not self.session_manager.has_unsaved_changes():
return True
choice = QMessageBox.question(
self, "Unsaved Changes",
"Save your changes before leaving this chat?",
QMessageBox.StandardButton.Save |
QMessageBox.StandardButton.Discard |
QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save
)
if choice == QMessageBox.StandardButton.Cancel:
return False
if choice == QMessageBox.StandardButton.Discard:
return True
try:
return self.session_manager.save_current_chat()
except Exception as exc:
QMessageBox.critical(self, "Save Failed", str(exc))
return False

def setup_toolbar(self, toolbar):
"""Setup toolbar with modern QToolButtons"""
Expand Down Expand Up @@ -351,6 +388,27 @@ def show_help(self):
center.y() - help_dialog.height() // 2)
help_dialog.show()

def verify_default_models(self):
"""Help first-time local users install every required Ollama task model."""
if api_provider.get_mode() != "Ollama":
return
try:
missing = api_provider.get_missing_ollama_models()
except Exception as exc:
QMessageBox.warning(
self, "Ollama Unavailable",
"Graphite could not verify local models. Start Ollama and check "
f"your connection. Details: {exc}"
)
return
if missing:
commands = "\n".join(f"ollama pull {model}" for model in missing)
QMessageBox.warning(
self, "Missing Ollama Models",
"Some local Graphite features need additional models. "
"Run these commands in a terminal:\n\n" + commands
)

def show_settings(self):
"""Show the appropriate settings dialog based on the current mode."""
use_api = self.mode_combo.currentData()
Expand All @@ -372,7 +430,18 @@ def on_mode_changed(self, index):
def setCurrentNode(self, node):
self.current_node = node
self.message_input.setPlaceholderText(f"Responding to: {node.text[:30]}...")


def build_conversation_history(self, node):
"""Read only the selected node's ancestor chain, oldest first."""
turns = []
while node is not None:
turns.append({
'role': 'user' if node.is_user else 'assistant',
'content': node.text
})
node = node.parent_node
return list(reversed(turns))

def send_message(self):
message = self.message_input.text().strip()
if not message:
Expand All @@ -385,15 +454,9 @@ def send_message(self):
# Show loading overlay
self.loading_overlay.show()

# Get conversation history up to current node
history = []
if self.current_node:
temp_node = self.current_node
while temp_node:
if hasattr(temp_node, 'conversation_history'):
history = temp_node.conversation_history + history
temp_node = temp_node.parent_node

# Ancestor nodes are the source of truth, not cumulative node histories.
history = self.build_conversation_history(self.current_node)

# Add user message node
user_node = self.chat_view.scene().add_chat_node(
message,
Expand Down Expand Up @@ -424,7 +487,7 @@ def handle_response(self, response, user_node):
response,
is_user=False,
parent_node=user_node,
conversation_history=self.agent.conversation_history + [
conversation_history=user_node.conversation_history + [
{'role': 'assistant', 'content': response}
]
)
Expand Down Expand Up @@ -639,17 +702,19 @@ def stop_all_workers(self):
self.chart_thread.wait()

def closeEvent(self, event):
"""
Overrides the default close event to ensure all background threads
are properly terminated before the application exits.
"""
"""Save or explicitly discard canvas edits before closing."""
if not self.confirm_session_transition():
event.ignore()
return
self.stop_all_workers()
super().closeEvent(event)


def main():
app = QApplication(sys.argv)
window = ChatWindow()
window.show()
QTimer.singleShot(0, window.verify_default_models)
sys.exit(app.exec())

if __name__ == "__main__":
Expand Down
Loading
Loading