refactor(aisidebar): restructure project and implement reasoning mode toggle
- Reorganize project structure and file locations - Add ReasoningController to manage model selection and reasoning mode - Update design and requirements for reasoning mode toggle - Implement model switching between Qwen3-4B-Instruct and Qwen3-4B-Thinking models - Remove deprecated files and consolidate project layout - Add new steering and specification documentation - Clean up and remove unnecessary files and directories - Prepare for enhanced AI sidebar functionality with more flexible model handling
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,8 +0,0 @@
|
|||||||
.venv/
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.pytest_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
data/conversations/*.json
|
|
||||||
data/conversations/index.json
|
|
||||||
data/conversations/*.tmp
|
|
||||||
@@ -233,7 +233,11 @@ class ConversationArchive:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
class ReasoningController:
|
class ReasoningController:
|
||||||
"""Manages reasoning mode state and API parameters."""
|
"""Manages reasoning mode state and model selection."""
|
||||||
|
|
||||||
|
# Model names for reasoning toggle
|
||||||
|
INSTRUCT_MODEL = "hf.co/unsloth/Qwen3-4B-Instruct-2507-GGUF:Q8_K_XL"
|
||||||
|
THINKING_MODEL = "hf.co/unsloth/Qwen3-4B-Thinking-2507-GGUF:Q8_K_XL"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._enabled = False
|
self._enabled = False
|
||||||
@@ -245,8 +249,9 @@ class ReasoningController:
|
|||||||
def toggle(self) -> bool:
|
def toggle(self) -> bool:
|
||||||
"""Toggle reasoning mode and persist preference."""
|
"""Toggle reasoning mode and persist preference."""
|
||||||
|
|
||||||
def get_chat_options(self) -> dict:
|
def get_model_name(self) -> str:
|
||||||
"""Return Ollama API options for reasoning mode."""
|
"""Return the appropriate model name based on reasoning mode."""
|
||||||
|
return self.THINKING_MODEL if self._enabled else self.INSTRUCT_MODEL
|
||||||
```
|
```
|
||||||
|
|
||||||
#### UI Components
|
#### UI Components
|
||||||
@@ -254,41 +259,35 @@ class ReasoningController:
|
|||||||
Add toggle button to header area:
|
Add toggle button to header area:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
self._reasoning_toggle = Gtk.ToggleButton(label="🧠 Reasoning")
|
self._reasoning_toggle = widgets.Button(label="🧠 Reasoning: OFF")
|
||||||
self._reasoning_toggle.connect("toggled", self._on_reasoning_toggled)
|
self._reasoning_toggle.connect("clicked", self._on_reasoning_toggled)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Ollama Integration
|
#### Ollama Integration
|
||||||
|
|
||||||
When reasoning mode is enabled, pass additional options to Ollama:
|
When reasoning mode is toggled, switch between models:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Standard mode
|
# Get model based on reasoning mode
|
||||||
ollama.chat(model=model, messages=messages)
|
model = self._reasoning_controller.get_model_name()
|
||||||
|
|
||||||
# Reasoning mode (model-dependent)
|
# Use the selected model for chat
|
||||||
ollama.chat(
|
ollama.chat(model=model, messages=messages)
|
||||||
model=model,
|
|
||||||
messages=messages,
|
|
||||||
options={
|
|
||||||
"temperature": 0.7,
|
|
||||||
# Model-specific reasoning parameters
|
|
||||||
}
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Message Formatting
|
#### Message Formatting
|
||||||
|
|
||||||
When reasoning is enabled and model supports it:
|
When using the thinking model:
|
||||||
- Display thinking process in distinct style (italic, gray text)
|
- Display thinking process in distinct style (italic, gray text)
|
||||||
- Separate reasoning from final answer with visual divider
|
- Separate reasoning from final answer with visual divider
|
||||||
- Use expandable/collapsible section for reasoning (optional)
|
- Parse `<think>` tags from model output to extract reasoning content
|
||||||
|
|
||||||
#### Persistence
|
#### Persistence
|
||||||
|
|
||||||
- Save reasoning preference to `~/.config/aisidebar/preferences.json`
|
- Save reasoning preference to `~/.config/aisidebar/preferences.json`
|
||||||
- Load preference on startup
|
- Load preference on startup
|
||||||
- Apply to all new conversations
|
- Apply to all new conversations
|
||||||
|
- Automatically switch models when preference changes
|
||||||
|
|
||||||
## Data Models
|
## Data Models
|
||||||
|
|
||||||
|
|||||||
@@ -62,15 +62,15 @@ This document outlines the requirements for enhancing the AI sidebar module for
|
|||||||
|
|
||||||
### Requirement 5: Reasoning Mode Toggle
|
### Requirement 5: Reasoning Mode Toggle
|
||||||
|
|
||||||
**User Story:** As a user, I want to enable or disable the model's reasoning output, so that I can choose whether to see the thinking process or just the final answer based on my needs.
|
**User Story:** As a user, I want to toggle between a reasoning model and an instruct model, so that I can choose whether to use a model that shows its thinking process or one that provides direct answers.
|
||||||
|
|
||||||
#### Acceptance Criteria
|
#### Acceptance Criteria
|
||||||
|
|
||||||
1. THE AI Sidebar SHALL provide a toggle button or control to enable reasoning mode
|
1. THE AI Sidebar SHALL provide a toggle button or control to switch between reasoning and instruct models
|
||||||
2. WHEN reasoning mode is enabled, THE AI Sidebar SHALL request and display the model's thinking process before the final answer
|
2. WHEN reasoning mode is enabled, THE AI Sidebar SHALL switch to the Qwen3-4B-Thinking model and display the model's thinking process
|
||||||
3. WHEN reasoning mode is disabled, THE AI Sidebar SHALL request and display only the final answer without intermediate reasoning
|
3. WHEN reasoning mode is disabled, THE AI Sidebar SHALL switch to the Qwen3-4B-Instruct model for direct answers
|
||||||
4. THE AI Sidebar SHALL persist the reasoning mode preference across conversation sessions
|
4. THE AI Sidebar SHALL persist the reasoning mode preference across conversation sessions
|
||||||
5. THE AI Sidebar SHALL visually distinguish reasoning content from final answer content when reasoning mode is enabled
|
5. THE AI Sidebar SHALL visually distinguish reasoning content from final answer content when using the thinking model
|
||||||
|
|
||||||
### Requirement 6: Graceful Ollama Unavailability Handling
|
### Requirement 6: Graceful Ollama Unavailability Handling
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
# Implementation Plan
|
# Implementation Plan
|
||||||
|
|
||||||
- [ ] 1. Implement streaming response infrastructure
|
- [x] 1. Implement streaming response infrastructure
|
||||||
- Create StreamingHandler class in new file `streaming_handler.py` with token buffering, UI update methods, and stream state management
|
- Create StreamingHandler class in new file `streaming_handler.py` with token buffering, UI update methods, and stream state management
|
||||||
- Add `_handle_stream_token()` method to SidebarWindow that uses GLib.idle_add for thread-safe UI updates
|
- Add `_handle_stream_token()` method to SidebarWindow that uses GLib.idle_add for thread-safe UI updates
|
||||||
- Implement token buffering logic (accumulate 3-5 tokens before UI update) to reduce overhead
|
- Implement token buffering logic (accumulate 3-5 tokens before UI update) to reduce overhead
|
||||||
- _Requirements: 1.1, 1.2, 1.3, 1.4_
|
- _Requirements: 1.1, 1.2, 1.3, 1.4_
|
||||||
|
|
||||||
- [ ] 2. Integrate streaming into SidebarWindow
|
- [x] 2. Integrate streaming into SidebarWindow
|
||||||
- Modify `_request_response()` to use `ollama_client.stream_chat()` instead of blocking `chat()`
|
- Modify `_request_response()` to use `ollama_client.stream_chat()` instead of blocking `chat()`
|
||||||
- Update worker thread to iterate over stream and call `_handle_stream_token()` for each chunk
|
- Update worker thread to iterate over stream and call `_handle_stream_token()` for each chunk
|
||||||
- Add streaming state indicator (visual feedback during generation)
|
- Add streaming state indicator (visual feedback during generation)
|
||||||
- Handle stream errors and interruptions gracefully with try-except blocks
|
- Handle stream errors and interruptions gracefully with try-except blocks
|
||||||
- _Requirements: 1.1, 1.2, 1.3, 1.4_
|
- _Requirements: 1.1, 1.2, 1.3, 1.4_
|
||||||
|
|
||||||
- [ ] 3. Replace single-line Entry with multi-line TextView
|
- [x] 3. Replace single-line Entry with multi-line TextView
|
||||||
- Replace `Gtk.Entry` with `Gtk.TextView` wrapped in `Gtk.ScrolledWindow` in `_build_ui()`
|
- Replace `Gtk.Entry` with `Gtk.TextView` wrapped in `Gtk.ScrolledWindow` in `_build_ui()`
|
||||||
- Configure text view with word wrapping, min height 40px, max height 200px
|
- Configure text view with word wrapping, min height 40px, max height 200px
|
||||||
- Implement key event controller to handle Enter (submit) vs Shift+Enter (newline)
|
- Implement key event controller to handle Enter (submit) vs Shift+Enter (newline)
|
||||||
@@ -21,37 +21,37 @@
|
|||||||
- Update `_on_submit()` to extract text from TextView buffer instead of Entry
|
- Update `_on_submit()` to extract text from TextView buffer instead of Entry
|
||||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||||
|
|
||||||
- [ ] 4. Create command processing system
|
- [x] 4. Create command processing system
|
||||||
- Create `command_processor.py` with CommandProcessor class
|
- Create `command_processor.py` with CommandProcessor class
|
||||||
- Implement command parsing logic with `is_command()` and `execute()` methods
|
- Implement command parsing logic with `is_command()` and `execute()` methods
|
||||||
- Define CommandResult dataclass for structured command responses
|
- Define CommandResult dataclass for structured command responses
|
||||||
- Add command registry dictionary mapping command strings to handler methods
|
- Add command registry dictionary mapping command strings to handler methods
|
||||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
|
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
|
||||||
|
|
||||||
- [ ] 5. Implement conversation management commands
|
- [x] 5. Implement conversation management commands
|
||||||
- [ ] 5.1 Implement `/new` and `/clear` commands
|
- [x] 5.1 Implement `/new` and `/clear` commands
|
||||||
- Add `_cmd_new_conversation()` method to save current conversation and reset to fresh state
|
- Add `_cmd_new_conversation()` method to save current conversation and reset to fresh state
|
||||||
- Clear message list UI and show confirmation message
|
- Clear message list UI and show confirmation message
|
||||||
- _Requirements: 3.1, 3.2_
|
- _Requirements: 3.1, 3.2_
|
||||||
|
|
||||||
- [ ] 5.2 Implement `/models` command
|
- [x] 5.2 Implement `/models` command
|
||||||
- Add `_cmd_list_models()` method to query and display available models
|
- Add `_cmd_list_models()` method to query and display available models
|
||||||
- Format model list with current model highlighted
|
- Format model list with current model highlighted
|
||||||
- _Requirements: 3.3_
|
- _Requirements: 3.3_
|
||||||
|
|
||||||
- [ ] 5.3 Implement `/model` command
|
- [x] 5.3 Implement `/model` command
|
||||||
- Add `_cmd_switch_model()` method to validate and switch active model
|
- Add `_cmd_switch_model()` method to validate and switch active model
|
||||||
- Update model label in header UI
|
- Update model label in header UI
|
||||||
- _Requirements: 3.4_
|
- _Requirements: 3.4_
|
||||||
|
|
||||||
- [ ] 5.4 Integrate CommandProcessor into SidebarWindow
|
- [x] 5.4 Integrate CommandProcessor into SidebarWindow
|
||||||
- Add CommandProcessor instance to SidebarWindow initialization
|
- Add CommandProcessor instance to SidebarWindow initialization
|
||||||
- Modify `_on_submit()` to check for commands before processing as user message
|
- Modify `_on_submit()` to check for commands before processing as user message
|
||||||
- Display command results as system messages with distinct styling
|
- Display command results as system messages with distinct styling
|
||||||
- _Requirements: 3.5_
|
- _Requirements: 3.5_
|
||||||
|
|
||||||
- [ ] 6. Implement conversation archive system
|
- [x] 6. Implement conversation archive system
|
||||||
- [ ] 6.1 Create ConversationArchive class
|
- [x] 6.1 Create ConversationArchive class
|
||||||
- Create `conversation_archive.py` with ConversationArchive class
|
- Create `conversation_archive.py` with ConversationArchive class
|
||||||
- Implement `list_conversations()` to scan storage directory and return metadata
|
- Implement `list_conversations()` to scan storage directory and return metadata
|
||||||
- Implement `archive_conversation()` to save with timestamp-based ID format
|
- Implement `archive_conversation()` to save with timestamp-based ID format
|
||||||
@@ -59,60 +59,60 @@
|
|||||||
- Define ConversationMetadata dataclass
|
- Define ConversationMetadata dataclass
|
||||||
- _Requirements: 4.1, 4.2_
|
- _Requirements: 4.1, 4.2_
|
||||||
|
|
||||||
- [ ] 6.2 Implement conversation loading
|
- [x] 6.2 Implement conversation loading
|
||||||
- Add `load_conversation()` method to ConversationArchive
|
- Add `load_conversation()` method to ConversationArchive
|
||||||
- Handle JSON parsing errors and missing files gracefully
|
- Handle JSON parsing errors and missing files gracefully
|
||||||
- Return ConversationState compatible with existing ConversationManager
|
- Return ConversationState compatible with existing ConversationManager
|
||||||
- _Requirements: 4.4_
|
- _Requirements: 4.4_
|
||||||
|
|
||||||
- [ ] 6.3 Implement `/list` and `/resume` commands
|
- [x] 6.3 Implement `/list` and `/resume` commands
|
||||||
- Add `_cmd_list_conversations()` to display archived conversations with metadata
|
- Add `_cmd_list_conversations()` to display archived conversations with metadata
|
||||||
- Add `_cmd_resume_conversation()` to load and display selected conversation
|
- Add `_cmd_resume_conversation()` to load and display selected conversation
|
||||||
- Update SidebarWindow to repopulate message list from loaded conversation
|
- Update SidebarWindow to repopulate message list from loaded conversation
|
||||||
- _Requirements: 4.3, 4.4, 4.5_
|
- _Requirements: 4.3, 4.4, 4.5_
|
||||||
|
|
||||||
- [ ] 7. Implement reasoning mode toggle
|
- [x] 7. Implement reasoning mode toggle
|
||||||
- [ ] 7.1 Create ReasoningController class
|
- [x] 7.1 Create ReasoningController class
|
||||||
- Create `reasoning_controller.py` with ReasoningController class
|
- Create `reasoning_controller.py` with ReasoningController class
|
||||||
- Implement preference persistence to `~/.config/aisidebar/preferences.json`
|
- Implement preference persistence to `~/.config/aisidebar/preferences.json`
|
||||||
- Add `toggle()`, `is_enabled()`, and `get_chat_options()` methods
|
- Add `toggle()`, `is_enabled()`, and `get_chat_options()` methods
|
||||||
- Define PreferencesState dataclass
|
- Define PreferencesState dataclass
|
||||||
- _Requirements: 5.4_
|
- _Requirements: 5.4_
|
||||||
|
|
||||||
- [ ] 7.2 Add reasoning toggle UI
|
- [x] 7.2 Add reasoning toggle UI
|
||||||
- Add ToggleButton to header area in `_build_ui()`
|
- Add ToggleButton to header area in `_build_ui()`
|
||||||
- Connect toggle signal to `_on_reasoning_toggled()` callback
|
- Connect toggle signal to `_on_reasoning_toggled()` callback
|
||||||
- Update button state from persisted preference on startup
|
- Update button state from persisted preference on startup
|
||||||
- _Requirements: 5.1_
|
- _Requirements: 5.1_
|
||||||
|
|
||||||
- [ ] 7.3 Integrate reasoning mode with Ollama calls
|
- [x] 7.3 Integrate reasoning mode with Ollama calls
|
||||||
- Modify `_request_response()` to include reasoning options when enabled
|
- Modify `_request_response()` to include reasoning options when enabled
|
||||||
- Pass model-specific parameters via `get_chat_options()`
|
- Pass model-specific parameters via `get_chat_options()`
|
||||||
- Handle both streaming and non-streaming modes with reasoning
|
- Handle both streaming and non-streaming modes with reasoning
|
||||||
- _Requirements: 5.2, 5.3_
|
- _Requirements: 5.2, 5.3_
|
||||||
|
|
||||||
- [ ] 7.4 Implement reasoning content formatting
|
- [x] 7.4 Implement reasoning content formatting
|
||||||
- Add visual distinction for reasoning content (italic, gray text, or expandable section)
|
- Add visual distinction for reasoning content (italic, gray text, or expandable section)
|
||||||
- Separate reasoning from final answer with visual divider
|
- Separate reasoning from final answer with visual divider
|
||||||
- Update message rendering to handle reasoning metadata
|
- Update message rendering to handle reasoning metadata
|
||||||
- _Requirements: 5.5_
|
- _Requirements: 5.5_
|
||||||
|
|
||||||
- [-] 8. Implement graceful Ollama unavailability handling
|
- [x] 8. Implement graceful Ollama unavailability handling
|
||||||
- [ ] 8.1 Update OllamaClient initialization
|
- [x] 8.1 Update OllamaClient initialization
|
||||||
- Modify `__init__()` to never raise exceptions during initialization
|
- Modify `__init__()` to never raise exceptions during initialization
|
||||||
- Add connection check that sets internal availability flag
|
- Add connection check that sets internal availability flag
|
||||||
- Update `list_models()` to return empty list instead of raising on connection failure
|
- Update `list_models()` to return empty list instead of raising on connection failure
|
||||||
- Update `chat()` and `stream_chat()` to return error messages instead of raising
|
- Update `chat()` and `stream_chat()` to return error messages instead of raising
|
||||||
- _Requirements: 6.1, 6.3, 6.5_
|
- _Requirements: 6.1, 6.3, 6.5_
|
||||||
|
|
||||||
- [ ] 8.2 Create OllamaAvailabilityMonitor
|
- [x] 8.2 Create OllamaAvailabilityMonitor
|
||||||
- Create `ollama_monitor.py` with OllamaAvailabilityMonitor class
|
- Create `ollama_monitor.py` with OllamaAvailabilityMonitor class
|
||||||
- Implement periodic availability checking using GLib.timeout_add (30s interval)
|
- Implement periodic availability checking using GLib.timeout_add (30s interval)
|
||||||
- Add callback mechanism to notify UI of state changes
|
- Add callback mechanism to notify UI of state changes
|
||||||
- Ensure checks are non-blocking and don't impact UI responsiveness
|
- Ensure checks are non-blocking and don't impact UI responsiveness
|
||||||
- _Requirements: 6.4_
|
- _Requirements: 6.4_
|
||||||
|
|
||||||
- [ ] 8.3 Update SidebarWindow for Ollama unavailability
|
- [x] 8.3 Update SidebarWindow for Ollama unavailability
|
||||||
- Initialize OllamaAvailabilityMonitor in SidebarWindow
|
- Initialize OllamaAvailabilityMonitor in SidebarWindow
|
||||||
- Display "Ollama not running" status message when unavailable at startup
|
- Display "Ollama not running" status message when unavailable at startup
|
||||||
- Update model label to show connection status
|
- Update model label to show connection status
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
- Add callback to re-enable features when Ollama becomes available
|
- Add callback to re-enable features when Ollama becomes available
|
||||||
- _Requirements: 6.1, 6.2, 6.4_
|
- _Requirements: 6.1, 6.2, 6.4_
|
||||||
|
|
||||||
- [ ] 8.4 Add user-friendly error messages
|
- [x] 8.4 Add user-friendly error messages
|
||||||
- Display clear instructions when user tries to chat without Ollama
|
- Display clear instructions when user tries to chat without Ollama
|
||||||
- Show notification when Ollama connection is restored
|
- Show notification when Ollama connection is restored
|
||||||
- Update all command handlers to check Ollama availability
|
- Update all command handlers to check Ollama availability
|
||||||
|
|||||||
23
.kiro/steering/product.md
Normal file
23
.kiro/steering/product.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
inclusion: always
|
||||||
|
---
|
||||||
|
|
||||||
|
# Product Overview
|
||||||
|
|
||||||
|
AI Sidebar is a slide-in chat interface for the Ignis desktop environment that provides local AI assistance through Ollama integration.
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
- Slide-in sidebar from the left side with smooth animations
|
||||||
|
- Local AI chat using Ollama models
|
||||||
|
- Automatic conversation persistence across sessions
|
||||||
|
- Material Design 3 theming that matches Ignis
|
||||||
|
- Keyboard shortcut toggle support
|
||||||
|
- Automatic Ollama availability monitoring with graceful degradation
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
- Clicking outside the sidebar closes it (same as QuickCenter)
|
||||||
|
- Conversations are automatically saved to `~/.config/ignis/modules/aisidebar/data/conversations/`
|
||||||
|
- The UI gracefully handles Ollama being unavailable and notifies users when connectivity is restored
|
||||||
|
- Default width is 400px to match QuickCenter
|
||||||
70
.kiro/steering/structure.md
Normal file
70
.kiro/steering/structure.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
inclusion: always
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Structure
|
||||||
|
|
||||||
|
## File Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
aisidebar/
|
||||||
|
├── __init__.py # Module exports (AISidebar class)
|
||||||
|
├── aisidebar.py # Main RevealerWindow implementation
|
||||||
|
├── chat_widget.py # Chat UI widget with message handling
|
||||||
|
├── ollama_client.py # HTTP client for Ollama REST API
|
||||||
|
├── ollama_monitor.py # Availability monitoring with callbacks
|
||||||
|
├── conversation_manager.py # Conversation persistence layer
|
||||||
|
└── data/
|
||||||
|
└── conversations/ # JSON conversation files (auto-created)
|
||||||
|
└── default.json # Default conversation transcript
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Responsibilities
|
||||||
|
|
||||||
|
### `aisidebar.py`
|
||||||
|
- Main window class extending `widgets.RevealerWindow`
|
||||||
|
- Handles slide-in animation from left side
|
||||||
|
- Manages window visibility and keyboard focus
|
||||||
|
- Integrates with Ignis WindowManager
|
||||||
|
|
||||||
|
### `chat_widget.py`
|
||||||
|
- Complete chat UI implementation
|
||||||
|
- Message list rendering and scrolling
|
||||||
|
- Input handling and submission
|
||||||
|
- Background thread management for AI requests
|
||||||
|
- Ollama availability monitoring integration
|
||||||
|
|
||||||
|
### `ollama_client.py`
|
||||||
|
- Low-level HTTP client for Ollama API
|
||||||
|
- Model listing with caching
|
||||||
|
- Blocking chat API calls
|
||||||
|
- Connection health checking
|
||||||
|
- Graceful error handling without exceptions
|
||||||
|
|
||||||
|
### `ollama_monitor.py`
|
||||||
|
- Periodic availability checking (30s interval)
|
||||||
|
- Callback-based state change notifications
|
||||||
|
- GLib timeout integration for non-blocking checks
|
||||||
|
|
||||||
|
### `conversation_manager.py`
|
||||||
|
- JSON-based conversation persistence
|
||||||
|
- Atomic file writes for data safety
|
||||||
|
- Message validation (system/user/assistant roles)
|
||||||
|
- Timestamp tracking for messages
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- Private methods/attributes: `_method_name`, `_attribute_name`
|
||||||
|
- Widget references: `self._widget_name` (e.g., `self._entry`, `self._message_list`)
|
||||||
|
- CSS classes: kebab-case (e.g., `ai-sidebar`, `ai-sidebar-content`)
|
||||||
|
- Constants: UPPER_SNAKE_CASE (e.g., `VALID_ROLES`, `DEFAULT_CONVERSATION_ID`)
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Type hints on function signatures
|
||||||
|
- Docstrings for classes and public methods
|
||||||
|
- Dataclasses for structured data (`ConversationState`)
|
||||||
|
- Context managers for file operations
|
||||||
|
- Property decorators for computed attributes
|
||||||
|
- Threading: daemon threads for background work
|
||||||
|
- Error messages: user-friendly with actionable instructions
|
||||||
63
.kiro/steering/tech.md
Normal file
63
.kiro/steering/tech.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
inclusion: always
|
||||||
|
---
|
||||||
|
|
||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
## Framework & Environment
|
||||||
|
|
||||||
|
- **Platform**: Ignis desktop environment (Python-based GTK4 framework)
|
||||||
|
- **Python Version**: 3.10+
|
||||||
|
- **UI Framework**: GTK4 via Ignis widgets
|
||||||
|
- **Async/Threading**: GLib for main loop, Python threading for background tasks
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- `ignis` - Desktop environment framework providing widgets and window management
|
||||||
|
- `ollama` - Python package for Ollama API integration
|
||||||
|
- GTK4 (`gi.repository.GLib`) - UI toolkit and event loop
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Widget System
|
||||||
|
- Uses Ignis widget abstractions (`widgets.Box`, `widgets.RevealerWindow`, etc.)
|
||||||
|
- Material Design 3 styling via CSS classes
|
||||||
|
- Revealer-based slide animations
|
||||||
|
|
||||||
|
### API Communication
|
||||||
|
- Direct HTTP calls to Ollama REST API (no external HTTP library)
|
||||||
|
- Uses `urllib.request` for HTTP operations
|
||||||
|
- Timeout handling: 2s for health checks, 5s for model lists, 120s for chat
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Conversation persistence via JSON files
|
||||||
|
- Atomic file writes using `tempfile` and `os.replace()`
|
||||||
|
- In-memory caching for model lists
|
||||||
|
|
||||||
|
### Threading Model
|
||||||
|
- UI operations on GLib main thread
|
||||||
|
- AI requests in background daemon threads
|
||||||
|
- `GLib.idle_add()` for thread-safe UI updates
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Graceful degradation when Ollama is unavailable
|
||||||
|
- Availability monitoring with 30-second polling interval
|
||||||
|
- User-facing error messages instead of exceptions
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
Since this is an Ignis module, there are no build/test commands. The module is loaded directly by Ignis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reload Ignis to apply changes
|
||||||
|
ignis reload
|
||||||
|
|
||||||
|
# Run Ignis with console output for debugging
|
||||||
|
ignis
|
||||||
|
|
||||||
|
# Check Ollama status
|
||||||
|
curl http://127.0.0.1:11434/api/tags
|
||||||
|
|
||||||
|
# List installed Ollama models
|
||||||
|
ollama list
|
||||||
|
```
|
||||||
38
AGENTS.md
38
AGENTS.md
@@ -1,38 +0,0 @@
|
|||||||
# Repository Guidelines
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
|
||||||
- `main.py` wires the GTK `Application` and guards headless runs; UI widgets live in `sidebar_window.py` and `message_widget.py`.
|
|
||||||
- `ollama_client.py` wraps streaming calls and threading helpers so GTK stays responsive.
|
|
||||||
- Conversation state persists through `conversation_manager.py` and JSON files under `data/conversations/`; keep writes atomic.
|
|
||||||
- Shared settings belong in `config.py`, styles in `styles.css`, and tooling defaults in `pyproject.toml`; prefer adding focused modules over bloating these.
|
|
||||||
- Tests mirror the source tree under `tests/`, with fixtures in `tests/fixtures/` for reusable transcripts and metadata.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
|
||||||
- `python -m venv .venv && source .venv/bin/activate` — creates and activates the project’s virtual environment.
|
|
||||||
- `pip install -r requirements.txt` — installs GTK, Ollama, and tooling dependencies.
|
|
||||||
- `python main.py` — launches the sidebar; requires a Wayland/X11 session.
|
|
||||||
- `AI_SIDEBAR_HEADLESS=1 python main.py` — skips GTK startup for CI smoke checks.
|
|
||||||
- `AI_SIDEBAR_HEADLESS=1 pytest` — runs the full test suite; combine with `-k "conversation"` for targeted checks.
|
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
|
||||||
- Use 4-space indentation and format with `black .`; avoid tab characters.
|
|
||||||
- Lint via `ruff check .` and fix violations instead of adding ignores.
|
|
||||||
- Files stay snake_case; classes use PascalCase; callbacks adopt verb-based snake_case (e.g., `handle_stream_chunk`).
|
|
||||||
- Keep GTK signal handlers thin and delegate behavior to helpers or managers.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
- Prefer `pytest` parameterized cases for conversation flows; store golden transcripts in `tests/fixtures/responses/`.
|
|
||||||
- Name tests `test_<module>_<behavior>` (e.g., `test_conversation_manager_persists_history`).
|
|
||||||
- Cover threading boundaries by mocking Ollama responses and asserting GTK updates via `GLib.idle_add`.
|
|
||||||
- Use `AI_SIDEBAR_HEADLESS=1` when exercising tests or scripts in non-GUI environments.
|
|
||||||
- Run `pytest --maxfail=1` before commits to catch regressions early.
|
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
|
||||||
- Follow the Conventional Commit pattern (`feat:`, `fix:`, `refactor:`) to keep the changelog clean.
|
|
||||||
- Keep commits scoped to a single concern and include tests or fixtures when changing behavior.
|
|
||||||
- Pull requests should summarize user-facing changes, list manual test steps, and attach screenshots or recordings for UI tweaks.
|
|
||||||
- Reference related issues with `Closes #ID` and call out follow-up tasks to keep planning clear.
|
|
||||||
|
|
||||||
## Agent Workflow Tips
|
|
||||||
- When prototyping new UI flows, add exploratory scripts under `experiments/` and clean them up before merging.
|
|
||||||
- Document new configuration toggles in `config.py` docstrings and echo them in the README so users can discover them easily.
|
|
||||||
212
FIXES.md
212
FIXES.md
@@ -1,212 +0,0 @@
|
|||||||
# GTK4 Layer Shell and Ollama Fixes
|
|
||||||
|
|
||||||
## Problems Identified
|
|
||||||
|
|
||||||
You were experiencing multiple issues when running the application:
|
|
||||||
|
|
||||||
1. **"Failed to initialize layer surface, not on Wayland"**
|
|
||||||
2. **Multiple "GtkWindow is not a layer surface"** warnings (9 times)
|
|
||||||
3. **"GtkText - did not receive a focus-out event"** warnings
|
|
||||||
4. **"No content received from Ollama"** - Ollama responses not working
|
|
||||||
|
|
||||||
## Root Causes
|
|
||||||
|
|
||||||
### 1. Wrong GDK Backend
|
|
||||||
Your environment had `GDK_BACKEND=x11` set, which forces GTK to use XWayland instead of native Wayland. GTK4 Layer Shell **only works with native Wayland**, not XWayland.
|
|
||||||
|
|
||||||
### 2. Initialization Order
|
|
||||||
The layer shell was being initialized **after** window properties (title, size) were set. GTK4 Layer Shell must be initialized **immediately** after `super().__init__()`.
|
|
||||||
|
|
||||||
### 3. Library Linking Order
|
|
||||||
The GTK4 Layer Shell library needs to be loaded before `libwayland-client.so`, which requires using `LD_PRELOAD`.
|
|
||||||
|
|
||||||
### 4. Missing Focus Event Handler
|
|
||||||
The `Gtk.Entry` widget wasn't properly handling focus-out events, causing GTK to emit warnings.
|
|
||||||
|
|
||||||
### 5. Virtual Environment Not Activated
|
|
||||||
The launcher script wasn't activating the Python virtual environment (`.venv`), so the `ollama` package wasn't available even though it was installed in the venv.
|
|
||||||
|
|
||||||
### 6. Ollama SDK API Change (Pydantic Objects)
|
|
||||||
The newer `ollama` package (v0.6.0) returns Pydantic objects instead of dictionaries. The `OllamaClient` code was using `.get()` methods which don't work on Pydantic objects, causing responses to appear empty. This caused all Ollama API calls to return empty content with "No content received from Ollama".
|
|
||||||
|
|
||||||
## Fixes Applied
|
|
||||||
|
|
||||||
### 1. Reordered Initialization ([sidebar_window.py:26-41](sidebar_window.py#L26-L41))
|
|
||||||
```python
|
|
||||||
def __init__(self, **kwargs) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
|
|
||||||
# CRITICAL: Layer shell must be initialized BEFORE any window properties
|
|
||||||
self._setup_layer_shell()
|
|
||||||
|
|
||||||
self.set_default_size(360, 720)
|
|
||||||
self.set_title("Niri AI Sidebar")
|
|
||||||
# ... rest of initialization
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Added Error Detection ([sidebar_window.py:44-65](sidebar_window.py#L44-L65))
|
|
||||||
```python
|
|
||||||
def _setup_layer_shell(self) -> None:
|
|
||||||
if Gtk4LayerShell is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
Gtk4LayerShell.init_for_window(self)
|
|
||||||
|
|
||||||
# Verify initialization succeeded before configuring
|
|
||||||
if not Gtk4LayerShell.is_layer_window(self):
|
|
||||||
return
|
|
||||||
|
|
||||||
# ... rest of layer shell configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Added Focus Event Handler ([sidebar_window.py:110-113](sidebar_window.py#L110-L113), [sidebar_window.py:173-176](sidebar_window.py#L173-L176))
|
|
||||||
```python
|
|
||||||
# Add focus event controller to properly propagate focus-out events
|
|
||||||
focus_controller = Gtk.EventControllerFocus()
|
|
||||||
focus_controller.connect("leave", self._on_entry_focus_out)
|
|
||||||
self._entry.add_controller(focus_controller)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Created Launcher Script ([run.sh](run.sh))
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# Get the directory where this script is located
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
|
|
||||||
# Activate virtual environment if it exists
|
|
||||||
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
|
||||||
source "$SCRIPT_DIR/.venv/bin/activate"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Force GTK to use native Wayland backend (not XWayland)
|
|
||||||
export GDK_BACKEND=wayland
|
|
||||||
|
|
||||||
# Preload GTK4 Layer Shell library to ensure proper initialization
|
|
||||||
export LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
exec python3 "$SCRIPT_DIR/main.py" "$@"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key additions:**
|
|
||||||
- Activates `.venv` if present (fixes Ollama integration)
|
|
||||||
- Sets `GDK_BACKEND=wayland` (forces native Wayland)
|
|
||||||
- Preloads GTK4 Layer Shell library (fixes linking order)
|
|
||||||
|
|
||||||
### 5. Added Environment Detection ([main.py:41-50](main.py#L41-L50))
|
|
||||||
Warns users if they're running with the wrong backend configuration.
|
|
||||||
|
|
||||||
### 6. Fixed Ollama SDK Compatibility ([ollama_client.py:59-76](ollama_client.py#L59-L76), [94-109](ollama_client.py#L94-L109))
|
|
||||||
Updated `OllamaClient` to handle both dictionary responses (old SDK) and Pydantic objects (new SDK v0.6.0+):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Handle both dict responses (old SDK) and Pydantic objects (new SDK)
|
|
||||||
if isinstance(result, dict):
|
|
||||||
message = result.get("message")
|
|
||||||
role = message.get("role") or "assistant"
|
|
||||||
content = message.get("content") or ""
|
|
||||||
else:
|
|
||||||
# Pydantic object (ollama SDK >= 0.4.0)
|
|
||||||
message = getattr(result, "message", None)
|
|
||||||
role = getattr(message, "role", "assistant")
|
|
||||||
content = getattr(message, "content", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
This ensures compatibility with both old and new versions of the `ollama` Python package.
|
|
||||||
|
|
||||||
## How to Run
|
|
||||||
|
|
||||||
**Use the launcher script:**
|
|
||||||
```bash
|
|
||||||
./run.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Or set environment variables manually:**
|
|
||||||
```bash
|
|
||||||
GDK_BACKEND=wayland LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so python3 main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Do NOT run directly with `python3 main.py`** if you have `GDK_BACKEND=x11` in your environment, as this will cause the layer shell initialization to fail.
|
|
||||||
|
|
||||||
## Expected Behavior
|
|
||||||
|
|
||||||
After these fixes:
|
|
||||||
- ✅ No "Failed to initialize layer surface" warnings
|
|
||||||
- ✅ No "GtkWindow is not a layer surface" warnings
|
|
||||||
- ✅ Reduced "GtkText - did not receive a focus-out event" warnings (GTK4 internal issue, mostly mitigated)
|
|
||||||
- ✅ Window properly anchored to the left edge of your screen
|
|
||||||
- ✅ Window appears as a layer surface in Niri
|
|
||||||
- ✅ Ollama integration working - receives and displays responses
|
|
||||||
- ✅ Conversation history persisted properly
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run the application with the launcher script and verify:
|
|
||||||
1. Minimal warnings in the console output (only harmless Vulkan warnings may appear)
|
|
||||||
2. Window appears on the left edge of the screen
|
|
||||||
3. Window stays anchored when switching workspaces
|
|
||||||
4. Text input works properly
|
|
||||||
5. Ollama responses are received and displayed
|
|
||||||
6. Conversations are saved and restored on restart
|
|
||||||
|
|
||||||
### Quick Test
|
|
||||||
```bash
|
|
||||||
./run.sh
|
|
||||||
# Type a message in the UI and press Enter
|
|
||||||
# You should see a response from Ollama
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "No content received from Ollama" Error
|
|
||||||
|
|
||||||
**Symptom:** The application displays "No content received from Ollama" or similar errors.
|
|
||||||
|
|
||||||
**Causes:**
|
|
||||||
1. The `ollama` Python package is not installed
|
|
||||||
2. The virtual environment is not activated
|
|
||||||
3. Ollama server is not running
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
```bash
|
|
||||||
# Ensure Ollama is installed and running
|
|
||||||
curl -s http://127.0.0.1:11434/api/tags
|
|
||||||
|
|
||||||
# Install the ollama package in your venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install ollama
|
|
||||||
|
|
||||||
# Always use the launcher script (it activates the venv)
|
|
||||||
./run.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Layer Shell Initialization Fails
|
|
||||||
|
|
||||||
**Symptom:** "Failed to initialize layer surface" warning appears.
|
|
||||||
|
|
||||||
**Causes:**
|
|
||||||
1. `GDK_BACKEND=x11` is set (forces XWayland instead of native Wayland)
|
|
||||||
2. GTK4 Layer Shell library not installed
|
|
||||||
3. Not running on a Wayland compositor
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
```bash
|
|
||||||
# Check your environment
|
|
||||||
echo $GDK_BACKEND # Should be empty or "wayland"
|
|
||||||
echo $WAYLAND_DISPLAY # Should show your Wayland display (e.g., "wayland-1")
|
|
||||||
|
|
||||||
# Unset GDK_BACKEND if it's set to x11
|
|
||||||
unset GDK_BACKEND
|
|
||||||
|
|
||||||
# Install GTK4 Layer Shell (Arch Linux)
|
|
||||||
sudo pacman -S gtk4-layer-shell
|
|
||||||
|
|
||||||
# Use the launcher script (it sets the correct environment)
|
|
||||||
./run.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [GTK4 Layer Shell Documentation](https://github.com/wmww/gtk4-layer-shell)
|
|
||||||
- [GTK4 Layer Shell Linking Guide](https://github.com/wmww/gtk4-layer-shell/blob/main/linking.md)
|
|
||||||
- [Ollama Python Library](https://github.com/ollama/ollama-python)
|
|
||||||
@@ -26,23 +26,23 @@ For **Niri**, add this to your `~/.config/niri/config.kdl`:
|
|||||||
binds {
|
binds {
|
||||||
// ... your other bindings
|
// ... your other bindings
|
||||||
|
|
||||||
// Toggle AI Sidebar with Super+A (or any key you prefer)
|
// Toggle AI Sidebar with Super+G (or any key you prefer)
|
||||||
Mod+A { spawn "ignis" "run" "ignis.window_manager.WindowManager.get_default().toggle_window('AISidebar')"; }
|
Mod+G { spawn "ignis" "run" "ignis.window_manager.WindowManager.get_default().toggle_window('AISidebar')"; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For **Hyprland**, add this to your `~/.config/hypr/hyprland.conf`:
|
For **Hyprland**, add this to your `~/.config/hypr/hyprland.conf`:
|
||||||
|
|
||||||
```conf
|
```conf
|
||||||
# Toggle AI Sidebar with Super+A
|
# Toggle AI Sidebar with Super+G
|
||||||
bind = SUPER, A, exec, ignis run "ignis.window_manager.WindowManager.get_default().toggle_window('AISidebar')"
|
bind = SUPER, G, exec, ignis run "ignis.window_manager.WindowManager.get_default().toggle_window('AISidebar')"
|
||||||
```
|
```
|
||||||
|
|
||||||
For **Sway**, add this to your `~/.config/sway/config`:
|
For **Sway**, add this to your `~/.config/sway/config`:
|
||||||
|
|
||||||
```
|
```
|
||||||
# Toggle AI Sidebar with Super+A
|
# Toggle AI Sidebar with Super+G
|
||||||
bindsym $mod+A exec ignis run "ignis.window_manager.WindowManager.get_default().toggle_window('AISidebar')"
|
bindsym $mod+G exec ignis run "ignis.window_manager.WindowManager.get_default().toggle_window('AISidebar')"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
BIN
__pycache__/__init__.cpython-313.pyc
Normal file
BIN
__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/aisidebar.cpython-313.pyc
Normal file
BIN
__pycache__/aisidebar.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/chat_widget.cpython-313.pyc
Normal file
BIN
__pycache__/chat_widget.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/command_processor.cpython-313.pyc
Normal file
BIN
__pycache__/command_processor.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/conversation_archive.cpython-313.pyc
Normal file
BIN
__pycache__/conversation_archive.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/conversation_manager.cpython-313.pyc
Normal file
BIN
__pycache__/conversation_manager.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/ollama_client.cpython-313.pyc
Normal file
BIN
__pycache__/ollama_client.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/ollama_monitor.cpython-313.pyc
Normal file
BIN
__pycache__/ollama_monitor.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/reasoning_controller.cpython-313.pyc
Normal file
BIN
__pycache__/reasoning_controller.cpython-313.pyc
Normal file
Binary file not shown.
BIN
__pycache__/streaming_handler.cpython-313.pyc
Normal file
BIN
__pycache__/streaming_handler.cpython-313.pyc
Normal file
Binary file not shown.
@@ -1,192 +0,0 @@
|
|||||||
import threading
|
|
||||||
from ignis import widgets, app
|
|
||||||
from gi.repository import GLib
|
|
||||||
from .ollama_client import OllamaClient
|
|
||||||
from .conversation_manager import ConversationManager
|
|
||||||
|
|
||||||
|
|
||||||
class ChatWidget(widgets.Box):
|
|
||||||
"""Chat interface widget with Ollama integration"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._conversation_manager = ConversationManager()
|
|
||||||
self._ollama_client = OllamaClient()
|
|
||||||
self._current_model = self._ollama_client.default_model
|
|
||||||
|
|
||||||
# Header with title and model
|
|
||||||
header_title = widgets.Label(
|
|
||||||
label="AI Sidebar",
|
|
||||||
halign="start",
|
|
||||||
css_classes=["title-2"],
|
|
||||||
)
|
|
||||||
|
|
||||||
model_name = self._current_model or "No local model detected"
|
|
||||||
|
|
||||||
self._model_label = widgets.Label(
|
|
||||||
label=f"Model: {model_name}",
|
|
||||||
halign="start",
|
|
||||||
css_classes=["dim-label"],
|
|
||||||
)
|
|
||||||
|
|
||||||
header_box = widgets.Box(
|
|
||||||
vertical=True,
|
|
||||||
spacing=4,
|
|
||||||
child=[header_title, self._model_label],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Message list
|
|
||||||
self._message_list = widgets.Box(
|
|
||||||
vertical=True,
|
|
||||||
spacing=8,
|
|
||||||
hexpand=True,
|
|
||||||
vexpand=True,
|
|
||||||
valign="start",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scrolled window for messages
|
|
||||||
self._scroller = widgets.Scroll(
|
|
||||||
hexpand=True,
|
|
||||||
vexpand=True,
|
|
||||||
min_content_height=300,
|
|
||||||
child=self._message_list,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Input entry
|
|
||||||
self._entry = widgets.Entry(
|
|
||||||
hexpand=True,
|
|
||||||
placeholder_text="Ask a question…",
|
|
||||||
on_accept=lambda x: self._on_submit(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send button
|
|
||||||
self._send_button = widgets.Button(
|
|
||||||
label="Send",
|
|
||||||
on_click=lambda x: self._on_submit(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Input box
|
|
||||||
input_box = widgets.Box(
|
|
||||||
spacing=8,
|
|
||||||
hexpand=True,
|
|
||||||
child=[self._entry, self._send_button],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Main container
|
|
||||||
super().__init__(
|
|
||||||
vertical=True,
|
|
||||||
spacing=12,
|
|
||||||
hexpand=True,
|
|
||||||
vexpand=True,
|
|
||||||
child=[header_box, self._scroller, input_box],
|
|
||||||
css_classes=["ai-sidebar-content"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set margins
|
|
||||||
self.set_margin_top(16)
|
|
||||||
self.set_margin_bottom(16)
|
|
||||||
self.set_margin_start(16)
|
|
||||||
self.set_margin_end(16)
|
|
||||||
|
|
||||||
# Load initial messages
|
|
||||||
self._populate_initial_messages()
|
|
||||||
|
|
||||||
def _populate_initial_messages(self):
|
|
||||||
"""Load conversation history"""
|
|
||||||
for message in self._conversation_manager.messages:
|
|
||||||
self._append_message(message["role"], message["content"], persist=False)
|
|
||||||
|
|
||||||
if not self._conversation_manager.messages:
|
|
||||||
self._append_message(
|
|
||||||
"assistant",
|
|
||||||
"Welcome! Ask a question to start a conversation.",
|
|
||||||
persist=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _append_message(self, role: str, content: str, *, persist: bool = True):
|
|
||||||
"""Add a message bubble to the chat"""
|
|
||||||
label_prefix = "You" if role == "user" else "Assistant"
|
|
||||||
|
|
||||||
message_label = widgets.Label(
|
|
||||||
label=f"{label_prefix}: {content}",
|
|
||||||
halign="start",
|
|
||||||
xalign=0.0,
|
|
||||||
wrap=True,
|
|
||||||
wrap_mode="word_char", # Fixed: use underscore not hyphen
|
|
||||||
justify="left",
|
|
||||||
)
|
|
||||||
|
|
||||||
self._message_list.append(message_label)
|
|
||||||
self._scroll_to_bottom()
|
|
||||||
|
|
||||||
if persist and self._conversation_manager:
|
|
||||||
self._conversation_manager.append_message(role, content)
|
|
||||||
|
|
||||||
def _scroll_to_bottom(self):
|
|
||||||
"""Scroll to the latest message"""
|
|
||||||
def _scroll():
|
|
||||||
adjustment = self._scroller.get_vadjustment()
|
|
||||||
if adjustment:
|
|
||||||
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size())
|
|
||||||
return False
|
|
||||||
|
|
||||||
GLib.idle_add(_scroll)
|
|
||||||
|
|
||||||
def _set_input_enabled(self, enabled: bool):
|
|
||||||
"""Enable/disable input controls"""
|
|
||||||
self._entry.set_sensitive(enabled)
|
|
||||||
self._send_button.set_sensitive(enabled)
|
|
||||||
|
|
||||||
def _on_submit(self):
|
|
||||||
"""Handle message submission"""
|
|
||||||
text = self._entry.text.strip()
|
|
||||||
if not text:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._entry.text = ""
|
|
||||||
self._append_message("user", text, persist=True)
|
|
||||||
self._request_response()
|
|
||||||
|
|
||||||
def _request_response(self):
|
|
||||||
"""Request AI response in background thread"""
|
|
||||||
model = self._current_model or self._ollama_client.default_model
|
|
||||||
if not model:
|
|
||||||
self._append_message(
|
|
||||||
"assistant",
|
|
||||||
"No Ollama models are available. Install a model to continue.",
|
|
||||||
persist=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
history = self._conversation_manager.chat_messages
|
|
||||||
self._set_input_enabled(False)
|
|
||||||
|
|
||||||
def _worker(messages):
|
|
||||||
response = self._ollama_client.chat(model=model, messages=list(messages))
|
|
||||||
GLib.idle_add(self._handle_response, response, priority=GLib.PRIORITY_DEFAULT)
|
|
||||||
|
|
||||||
thread = threading.Thread(target=_worker, args=(history,), daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
def _handle_response(self, response):
|
|
||||||
"""Handle AI response"""
|
|
||||||
self._set_input_enabled(True)
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
self._append_message(
|
|
||||||
"assistant",
|
|
||||||
"The model returned an empty response.",
|
|
||||||
persist=True,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
role = response.get("role", "assistant")
|
|
||||||
content = response.get("content") or ""
|
|
||||||
if not content:
|
|
||||||
content = "[No content received from Ollama]"
|
|
||||||
|
|
||||||
self._append_message(role, content, persist=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def focus_input(self):
|
|
||||||
"""Focus the input entry"""
|
|
||||||
self._entry.grab_focus()
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
"""Conversation state management and persistence helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import ClassVar, Dict, Iterable, List, MutableMapping
|
|
||||||
|
|
||||||
DEFAULT_CONVERSATION_ID = "default"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ConversationState:
|
|
||||||
"""In-memory representation of a conversation transcript."""
|
|
||||||
|
|
||||||
conversation_id: str
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
messages: List[Dict[str, str]] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class ConversationManager:
|
|
||||||
"""Load and persist conversation transcripts as JSON files."""
|
|
||||||
|
|
||||||
VALID_ROLES: ClassVar[set[str]] = {"system", "user", "assistant"}
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
storage_dir: str | Path | None = None,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
module_root = Path(__file__).resolve().parent
|
|
||||||
default_storage = module_root / "data" / "conversations"
|
|
||||||
self._storage_dir = Path(storage_dir) if storage_dir else default_storage
|
|
||||||
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
self._conversation_id = conversation_id or DEFAULT_CONVERSATION_ID
|
|
||||||
self._path = self._storage_dir / f"{self._conversation_id}.json"
|
|
||||||
|
|
||||||
self._state = self._load_state()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ properties
|
|
||||||
@property
|
|
||||||
def conversation_id(self) -> str:
|
|
||||||
return self._state.conversation_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def messages(self) -> List[Dict[str, str]]:
|
|
||||||
return list(self._state.messages)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def chat_messages(self) -> List[Dict[str, str]]:
|
|
||||||
"""Return messages formatted for the Ollama chat API."""
|
|
||||||
return [
|
|
||||||
{"role": msg["role"], "content": msg["content"]}
|
|
||||||
for msg in self._state.messages
|
|
||||||
]
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ public API
|
|
||||||
def append_message(self, role: str, content: str) -> Dict[str, str]:
|
|
||||||
"""Append a new message and persist the updated transcript."""
|
|
||||||
normalized_role = role.lower()
|
|
||||||
if normalized_role not in self.VALID_ROLES:
|
|
||||||
raise ValueError(f"Invalid role '{role}'. Expected one of {self.VALID_ROLES}.")
|
|
||||||
|
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
|
||||||
message = {
|
|
||||||
"role": normalized_role,
|
|
||||||
"content": content,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
}
|
|
||||||
|
|
||||||
self._state.messages.append(message)
|
|
||||||
self._state.updated_at = timestamp
|
|
||||||
self._write_state()
|
|
||||||
return message
|
|
||||||
|
|
||||||
def replace_messages(self, messages: Iterable[Dict[str, str]]) -> None:
|
|
||||||
"""Replace the transcript contents. Useful for loading fixtures."""
|
|
||||||
normalized: List[Dict[str, str]] = []
|
|
||||||
for item in messages:
|
|
||||||
role = item.get("role", "").lower()
|
|
||||||
content = item.get("content", "")
|
|
||||||
if role not in self.VALID_ROLES:
|
|
||||||
continue
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"role": role,
|
|
||||||
"content": content,
|
|
||||||
"timestamp": item.get("timestamp")
|
|
||||||
or datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
self._state.messages = normalized
|
|
||||||
self._state.created_at = self._state.created_at or now
|
|
||||||
self._state.updated_at = now
|
|
||||||
self._write_state()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ persistence
|
|
||||||
def _load_state(self) -> ConversationState:
|
|
||||||
"""Load the transcript from disk or create a fresh default."""
|
|
||||||
if self._path.exists():
|
|
||||||
try:
|
|
||||||
with self._path.open("r", encoding="utf-8") as fh:
|
|
||||||
payload = json.load(fh)
|
|
||||||
return self._state_from_payload(payload)
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
|
||||||
return ConversationState(
|
|
||||||
conversation_id=self._conversation_id,
|
|
||||||
created_at=timestamp,
|
|
||||||
updated_at=timestamp,
|
|
||||||
messages=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
def _state_from_payload(self, payload: MutableMapping[str, object]) -> ConversationState:
|
|
||||||
"""Normalize persisted data into ConversationState instances."""
|
|
||||||
conversation_id = str(payload.get("id") or self._conversation_id)
|
|
||||||
created_at = str(payload.get("created_at") or datetime.now(timezone.utc).isoformat())
|
|
||||||
updated_at = str(payload.get("updated_at") or created_at)
|
|
||||||
|
|
||||||
messages_payload = payload.get("messages", [])
|
|
||||||
messages: List[Dict[str, str]] = []
|
|
||||||
if isinstance(messages_payload, list):
|
|
||||||
for item in messages_payload:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
role = str(item.get("role", "")).lower()
|
|
||||||
content = str(item.get("content", ""))
|
|
||||||
if role not in self.VALID_ROLES:
|
|
||||||
continue
|
|
||||||
timestamp = str(
|
|
||||||
item.get("timestamp") or datetime.now(timezone.utc).isoformat()
|
|
||||||
)
|
|
||||||
messages.append({"role": role, "content": content, "timestamp": timestamp})
|
|
||||||
|
|
||||||
return ConversationState(
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
created_at=created_at,
|
|
||||||
updated_at=updated_at,
|
|
||||||
messages=messages,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _write_state(self) -> None:
|
|
||||||
"""Persist the conversation state atomically."""
|
|
||||||
payload = {
|
|
||||||
"id": self._state.conversation_id,
|
|
||||||
"created_at": self._state.created_at,
|
|
||||||
"updated_at": self._state.updated_at,
|
|
||||||
"messages": self._state.messages,
|
|
||||||
}
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
"w",
|
|
||||||
encoding="utf-8",
|
|
||||||
dir=self._storage_dir,
|
|
||||||
delete=False,
|
|
||||||
prefix=f"{self._conversation_id}.",
|
|
||||||
suffix=".tmp",
|
|
||||||
) as tmp_file:
|
|
||||||
json.dump(payload, tmp_file, indent=2, ensure_ascii=False)
|
|
||||||
tmp_file.flush()
|
|
||||||
os.fsync(tmp_file.fileno())
|
|
||||||
|
|
||||||
os.replace(tmp_file.name, self._path)
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
"""Client utilities for interacting with the Ollama API via direct HTTP calls."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any, Dict, Iterable, Iterator
|
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
from urllib.error import URLError, HTTPError
|
|
||||||
|
|
||||||
|
|
||||||
class OllamaClientError(RuntimeError):
|
|
||||||
"""Base exception raised when Ollama operations fail."""
|
|
||||||
|
|
||||||
|
|
||||||
class OllamaUnavailableError(OllamaClientError):
|
|
||||||
"""Raised when the Ollama server is not available."""
|
|
||||||
|
|
||||||
|
|
||||||
class OllamaClient:
|
|
||||||
"""HTTP client for interacting with Ollama's REST API."""
|
|
||||||
|
|
||||||
def __init__(self, host: str | None = None) -> None:
|
|
||||||
self._host = host or "http://localhost:11434"
|
|
||||||
self._cached_models: list[str] | None = None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ helpers
|
|
||||||
@property
|
|
||||||
def is_available(self) -> bool:
|
|
||||||
"""Check if Ollama server is reachable."""
|
|
||||||
try:
|
|
||||||
req = Request(f"{self._host}/api/tags", method="GET")
|
|
||||||
with urlopen(req, timeout=2) as response:
|
|
||||||
return response.status == 200
|
|
||||||
except (URLError, HTTPError, TimeoutError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def default_model(self) -> str | None:
|
|
||||||
"""Get the first available model."""
|
|
||||||
models = self.list_models()
|
|
||||||
return models[0] if models else None
|
|
||||||
|
|
||||||
def list_models(self, force_refresh: bool = False) -> list[str]:
|
|
||||||
"""Return the available model names, caching the result for quick reuse."""
|
|
||||||
if self._cached_models is not None and not force_refresh:
|
|
||||||
return list(self._cached_models)
|
|
||||||
|
|
||||||
try:
|
|
||||||
req = Request(f"{self._host}/api/tags", method="GET")
|
|
||||||
with urlopen(req, timeout=5) as response:
|
|
||||||
data = json.loads(response.read().decode())
|
|
||||||
except (URLError, HTTPError, TimeoutError) as exc:
|
|
||||||
raise OllamaClientError(f"Failed to list models: {exc}") from exc
|
|
||||||
|
|
||||||
models: list[str] = []
|
|
||||||
for item in data.get("models", []):
|
|
||||||
name = item.get("name") or item.get("model")
|
|
||||||
if name:
|
|
||||||
models.append(name)
|
|
||||||
|
|
||||||
self._cached_models = models
|
|
||||||
return list(models)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ chat APIs
|
|
||||||
def chat(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
model: str,
|
|
||||||
messages: Iterable[Dict[str, str]],
|
|
||||||
) -> dict[str, str] | None:
|
|
||||||
"""Execute a blocking chat call against Ollama."""
|
|
||||||
payload = {
|
|
||||||
"model": model,
|
|
||||||
"messages": list(messages),
|
|
||||||
"stream": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
req = Request(
|
|
||||||
f"{self._host}/api/chat",
|
|
||||||
data=json.dumps(payload).encode("utf-8"),
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urlopen(req, timeout=120) as response:
|
|
||||||
result = json.loads(response.read().decode())
|
|
||||||
except (URLError, HTTPError, TimeoutError) as exc:
|
|
||||||
return {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": f"Unable to reach Ollama: {exc}",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Parse the response
|
|
||||||
message = result.get("message")
|
|
||||||
if not message:
|
|
||||||
return {"role": "assistant", "content": ""}
|
|
||||||
|
|
||||||
role = message.get("role", "assistant")
|
|
||||||
content = message.get("content", "")
|
|
||||||
|
|
||||||
return {"role": role, "content": content}
|
|
||||||
|
|
||||||
def stream_chat(
|
|
||||||
self, *, model: str, messages: Iterable[Dict[str, str]]
|
|
||||||
) -> Iterator[dict[str, Any]]:
|
|
||||||
"""Placeholder for streaming API - not yet implemented."""
|
|
||||||
raise NotImplementedError("Streaming chat is not yet implemented")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ internals
|
|
||||||
def _make_request(
|
|
||||||
self, endpoint: str, method: str = "GET", data: dict | None = None
|
|
||||||
) -> dict:
|
|
||||||
"""Make an HTTP request to the Ollama API."""
|
|
||||||
url = f"{self._host}{endpoint}"
|
|
||||||
|
|
||||||
if data:
|
|
||||||
req = Request(
|
|
||||||
url,
|
|
||||||
data=json.dumps(data).encode("utf-8"),
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method=method,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
req = Request(url, method=method)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urlopen(req, timeout=30) as response:
|
|
||||||
return json.loads(response.read().decode())
|
|
||||||
except (URLError, HTTPError) as exc:
|
|
||||||
raise OllamaClientError(f"Request failed: {exc}") from exc
|
|
||||||
1072
chat_widget.py
Normal file
1072
chat_widget.py
Normal file
File diff suppressed because it is too large
Load Diff
87
command_processor.py
Normal file
87
command_processor.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Command processing system for AI Sidebar slash commands."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandResult:
|
||||||
|
"""Result of command execution."""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
data: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommandProcessor:
|
||||||
|
"""Parses and executes slash commands."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize command processor with command registry."""
|
||||||
|
self._handlers: dict[str, Callable] = {}
|
||||||
|
|
||||||
|
def register_command(self, command: str, handler: Callable) -> None:
|
||||||
|
"""
|
||||||
|
Register a command handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Command string (e.g., "/new", "/model")
|
||||||
|
handler: Callable that takes command arguments and returns CommandResult
|
||||||
|
"""
|
||||||
|
self._handlers[command.lower()] = handler
|
||||||
|
|
||||||
|
def is_command(self, text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if text starts with a command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: User input text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if text starts with a registered command
|
||||||
|
"""
|
||||||
|
if not text or not text.startswith("/"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Extract command (first word)
|
||||||
|
parts = text.strip().split(maxsplit=1)
|
||||||
|
command = parts[0].lower()
|
||||||
|
|
||||||
|
return command in self._handlers
|
||||||
|
|
||||||
|
def execute(self, text: str) -> CommandResult:
|
||||||
|
"""
|
||||||
|
Parse and execute command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: User input text starting with command
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommandResult with execution status and message
|
||||||
|
"""
|
||||||
|
if not text or not text.startswith("/"):
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message="Invalid command format. Commands must start with '/'."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse command and arguments
|
||||||
|
parts = text.strip().split(maxsplit=1)
|
||||||
|
command = parts[0].lower()
|
||||||
|
args = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
# Check if command is registered
|
||||||
|
if command not in self._handlers:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Unknown command: {command}. Type /help for available commands."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute command handler
|
||||||
|
try:
|
||||||
|
handler = self._handlers[command]
|
||||||
|
return handler(args)
|
||||||
|
except Exception as e:
|
||||||
|
return CommandResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Error executing command: {str(e)}"
|
||||||
|
)
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Configuration defaults and helpers for the AI sidebar."""
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
"""Placeholder configuration container."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
raise NotImplementedError("Configuration logic not implemented yet.")
|
|
||||||
180
conversation_archive.py
Normal file
180
conversation_archive.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""Conversation archive management for multi-conversation persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from .conversation_manager import ConversationState, DEFAULT_CONVERSATION_ID
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversationMetadata:
|
||||||
|
"""Metadata for conversation list display."""
|
||||||
|
|
||||||
|
archive_id: str
|
||||||
|
title: str # AI-generated or user-provided title
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
message_count: int
|
||||||
|
preview: str # First 50 chars of first user message
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationArchive:
|
||||||
|
"""Manages multiple conversation files with archiving capabilities."""
|
||||||
|
|
||||||
|
def __init__(self, storage_dir: str | Path | None = None) -> None:
|
||||||
|
if storage_dir is None:
|
||||||
|
module_root = Path(__file__).resolve().parent
|
||||||
|
storage_dir = module_root / "data" / "conversations"
|
||||||
|
|
||||||
|
self._storage_dir = Path(storage_dir)
|
||||||
|
self._storage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def generate_archive_id(self) -> str:
|
||||||
|
"""Create unique archive ID: YYYYMMDD_HHMMSS_<short-hash>."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
timestamp_part = now.strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# Generate short hash from timestamp + microseconds for uniqueness
|
||||||
|
hash_input = f"{now.isoformat()}{now.microsecond}".encode("utf-8")
|
||||||
|
hash_digest = hashlib.sha256(hash_input).hexdigest()[:8]
|
||||||
|
|
||||||
|
return f"archive_{timestamp_part}_{hash_digest}"
|
||||||
|
|
||||||
|
def archive_conversation(
|
||||||
|
self,
|
||||||
|
conversation_state: ConversationState,
|
||||||
|
archive_id: str | None = None,
|
||||||
|
title: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Save conversation with timestamp-based archive ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_state: The conversation to archive
|
||||||
|
archive_id: Optional custom archive ID, generates one if not provided
|
||||||
|
title: Optional title for the conversation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The archive ID used for the saved conversation
|
||||||
|
"""
|
||||||
|
if archive_id is None:
|
||||||
|
archive_id = self.generate_archive_id()
|
||||||
|
|
||||||
|
archive_path = self._storage_dir / f"{archive_id}.json"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"id": archive_id,
|
||||||
|
"title": title or "",
|
||||||
|
"created_at": conversation_state.created_at,
|
||||||
|
"updated_at": conversation_state.updated_at,
|
||||||
|
"messages": conversation_state.messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
with archive_path.open("w", encoding="utf-8") as fh:
|
||||||
|
json.dump(payload, fh, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
return archive_id
|
||||||
|
|
||||||
|
def list_conversations(self) -> List[ConversationMetadata]:
|
||||||
|
"""Return metadata for all saved conversations.
|
||||||
|
|
||||||
|
Scans the storage directory for conversation files and extracts metadata.
|
||||||
|
Excludes the default.json active conversation file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ConversationMetadata sorted by updated_at (newest first)
|
||||||
|
"""
|
||||||
|
conversations: List[ConversationMetadata] = []
|
||||||
|
|
||||||
|
for json_file in self._storage_dir.glob("*.json"):
|
||||||
|
# Skip the default active conversation
|
||||||
|
if json_file.stem == DEFAULT_CONVERSATION_ID:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with json_file.open("r", encoding="utf-8") as fh:
|
||||||
|
payload = json.load(fh)
|
||||||
|
|
||||||
|
archive_id = payload.get("id", json_file.stem)
|
||||||
|
title = payload.get("title", "")
|
||||||
|
created_at = payload.get("created_at", "")
|
||||||
|
updated_at = payload.get("updated_at", created_at)
|
||||||
|
messages = payload.get("messages", [])
|
||||||
|
message_count = len(messages)
|
||||||
|
|
||||||
|
# Extract preview from first user message
|
||||||
|
preview = ""
|
||||||
|
for msg in messages:
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
content = msg.get("content", "")
|
||||||
|
preview = content[:50]
|
||||||
|
if len(content) > 50:
|
||||||
|
preview += "..."
|
||||||
|
break
|
||||||
|
|
||||||
|
# Use title if available, otherwise use archive_id
|
||||||
|
display_title = title if title else archive_id
|
||||||
|
|
||||||
|
metadata = ConversationMetadata(
|
||||||
|
archive_id=archive_id,
|
||||||
|
title=display_title,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=updated_at,
|
||||||
|
message_count=message_count,
|
||||||
|
preview=preview,
|
||||||
|
)
|
||||||
|
conversations.append(metadata)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, OSError, KeyError):
|
||||||
|
# Skip corrupted or inaccessible files
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by updated_at, newest first
|
||||||
|
conversations.sort(key=lambda c: c.updated_at, reverse=True)
|
||||||
|
|
||||||
|
return conversations
|
||||||
|
|
||||||
|
def load_conversation(self, archive_id: str) -> ConversationState | None:
|
||||||
|
"""Load archived conversation by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
archive_id: The ID of the conversation to load
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ConversationState if found and valid, None otherwise
|
||||||
|
"""
|
||||||
|
archive_path = self._storage_dir / f"{archive_id}.json"
|
||||||
|
|
||||||
|
if not archive_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with archive_path.open("r", encoding="utf-8") as fh:
|
||||||
|
payload = json.load(fh)
|
||||||
|
|
||||||
|
conversation_id = payload.get("id", archive_id)
|
||||||
|
created_at = payload.get("created_at", datetime.now(timezone.utc).isoformat())
|
||||||
|
updated_at = payload.get("updated_at", created_at)
|
||||||
|
messages = payload.get("messages", [])
|
||||||
|
|
||||||
|
# Validate messages structure
|
||||||
|
validated_messages = []
|
||||||
|
for msg in messages:
|
||||||
|
if isinstance(msg, dict) and "role" in msg and "content" in msg:
|
||||||
|
validated_messages.append(msg)
|
||||||
|
|
||||||
|
return ConversationState(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=updated_at,
|
||||||
|
messages=validated_messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, OSError, KeyError):
|
||||||
|
# Handle JSON parsing errors and missing files gracefully
|
||||||
|
return None
|
||||||
@@ -102,6 +102,39 @@ class ConversationManager:
|
|||||||
self._state.updated_at = now
|
self._state.updated_at = now
|
||||||
self._write_state()
|
self._write_state()
|
||||||
|
|
||||||
|
def clear_messages(self) -> None:
|
||||||
|
"""Clear all messages and reset the conversation state."""
|
||||||
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
self._state = ConversationState(
|
||||||
|
conversation_id=self._conversation_id,
|
||||||
|
created_at=timestamp,
|
||||||
|
updated_at=timestamp,
|
||||||
|
messages=[],
|
||||||
|
)
|
||||||
|
self._write_state()
|
||||||
|
|
||||||
|
def trim_to_recent(self, keep_count: int = 20) -> List[Dict[str, str]]:
|
||||||
|
"""Trim conversation to keep only recent messages, return removed messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keep_count: Number of recent messages to keep
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of messages that were removed (older messages)
|
||||||
|
"""
|
||||||
|
if len(self._state.messages) <= keep_count:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Split messages into old and recent
|
||||||
|
removed_messages = self._state.messages[:-keep_count]
|
||||||
|
self._state.messages = self._state.messages[-keep_count:]
|
||||||
|
|
||||||
|
# Update state and persist
|
||||||
|
self._state.updated_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
self._write_state()
|
||||||
|
|
||||||
|
return removed_messages
|
||||||
|
|
||||||
# ------------------------------------------------------------------ persistence
|
# ------------------------------------------------------------------ persistence
|
||||||
def _load_state(self) -> ConversationState:
|
def _load_state(self) -> ConversationState:
|
||||||
"""Load the transcript from disk or create a fresh default."""
|
"""Load the transcript from disk or create a fresh default."""
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
93
data/conversations/archive_20251026_064314_9ec25159.json
Normal file
93
data/conversations/archive_20251026_064314_9ec25159.json
Normal file
File diff suppressed because one or more lines are too long
23
data/conversations/archive_20251026_064414_f8639758.json
Normal file
23
data/conversations/archive_20251026_064414_f8639758.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_064414_f8639758",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T06:43:14.550171+00:00",
|
||||||
|
"updated_at": "2025-10-26T06:43:47.212458+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T06:43:14.554066+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Whats up?",
|
||||||
|
"timestamp": "2025-10-26T06:43:44.177353+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just said \"Whats up?\" which is a casual greeting. I need to respond in a friendly and open manner. Since they're starting a new conversation, I should keep it light and inviting. Maybe ask how they're doing or what they need help with. Let me make sure the tone is positive and approachable. I should avoid any formal language and keep it simple. Also, since the previous conversation was archived, there's no need to reference that. Just focus on the current interaction. Let me check for any typos and ensure the response flows naturally.\n</think>\n\nHey there! Just checking in to see how you're doing. What's on your mind today? 😊 I'm here to help with anything you need!",
|
||||||
|
"timestamp": "2025-10-26T06:43:47.212458+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
data/conversations/archive_20251026_064712_da9eaa9a.json
Normal file
23
data/conversations/archive_20251026_064712_da9eaa9a.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_064712_da9eaa9a",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T06:44:14.844517+00:00",
|
||||||
|
"updated_at": "2025-10-26T06:47:01.787913+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T06:44:14.848276+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What can you program",
|
||||||
|
"timestamp": "2025-10-26T06:46:48.452468+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user asked, \"What can you program?\" I need to explain the range of programming tasks I can handle. First, I should list the main areas like coding in various languages, web development, data analysis, machine learning, etc. But I should be clear that I can't execute code, only provide guidance.\n\nWait, the user might be a beginner, so I should explain in simple terms. Maybe start with the languages I support, like Python, JavaScript, Java, etc. Then mention specific tasks such as creating scripts, building websites, analyzing data, developing apps. Also, include AI-related tasks like NLP or image recognition.\n\nI should highlight that I can't run code but can help write it. Maybe add examples of common tasks to make it concrete. Also, mention collaboration, like helping debug or optimize code. Keep the tone friendly and encouraging. Make sure to invite them to ask for specific help if they have a project in mind. Avoid technical jargon where possible. Check for clarity and ensure all key points are covered without being too verbose.\n</think>\n\nI can assist with a wide range of programming tasks and concepts! Here's what I can help with:\n\n### **Languages & Tools**\n- **Scripting**: Python, JavaScript, Bash, PowerShell, etc.\n- **Web Development**: HTML, CSS, JavaScript (frontend/backend), frameworks like React, Node.js, Django, etc.\n- **Data Science**: Python (Pandas, NumPy, Matplotlib), R, SQL, data visualization.\n- **Machine Learning/AI**: Python libraries (TensorFlow, PyTorch, scikit-learn), NLP, image processing, model training.\n- **Mobile/Web Apps**: React Native, Flutter, Flutter Web, etc.\n- **Game Development**: Unity (C#), Godot (GDScript), etc.\n- **Automation**: Scripting for repetitive tasks, API integrations, CI/CD pipelines.\n\n### **Tasks I Can Help With**\n1. **Code Writing/Debugging**: \n - Write code for specific tasks (e.g., \"Create a Python script to sort a list\"). \n - Fix errors in existing code. \n - Optimize performance or readability.\n\n2. **Project Planning**: \n - Design architecture for apps, websites, or systems. \n - Suggest tools/libraries for a project.\n\n3. **Tutorials & Explanations**: \n - Explain concepts (e.g., \"How does recursion work?\"). \n - Break down complex topics into simple steps. \n - Provide examples for learning.\n\n4. **Collaboration**: \n - Help brainstorm solutions to problems. \n - Review code logic or structure. \n - Suggest best practices (e.g., code style, security, scalability).\n\n5. **AI/ML Tasks**: \n - Build models for classification, regression, or NLP. \n - Analyze datasets and generate insights. \n - Deploy models using frameworks like Flask or FastAPI.\n\n### **Limitations**\n- I **can't execute code** or run programs directly. \n- I can't access external systems, files, or live data. \n- I provide **guidance and suggestions**, not guaranteed solutions.\n\n---\n\n**How can I help you?** \nIf you have a specific project, problem, or question (e.g., \"How to build a weather app in Python?\" or \"Fix this SQL query\"), feel free to ask! I’ll walk you through the steps. 😊",
|
||||||
|
"timestamp": "2025-10-26T06:47:01.787913+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
data/conversations/archive_20251026_064923_2820888b.json
Normal file
23
data/conversations/archive_20251026_064923_2820888b.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_064923_2820888b",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T06:47:12.732213+00:00",
|
||||||
|
"updated_at": "2025-10-26T06:49:05.777661+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T06:47:12.735915+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What is 5x5",
|
||||||
|
"timestamp": "2025-10-26T06:48:58.489714+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user asked \"What is 5x5\". Let me think about how to approach this.\n\nFirst, I need to confirm what exactly they're asking. The question is straightforward, but maybe they want a simple multiplication result or something more. Since it's 5 multiplied by 5, the answer is 25. But maybe they're looking for a different interpretation? Like a 5x5 grid or matrix? But given the context, it's most likely a basic math question.\n\nI should start by providing the direct answer: 5 times 5 equals 25. Then, perhaps offer additional context in case they need more information. For example, if they're working on a math problem, they might need to know how to solve it step by step. Or if they're using it in a different context, like a 5x5 matrix in programming or a grid in design.\n\nWait, the user might be a student learning multiplication, so explaining the process could be helpful. But the question is very simple, so maybe they just need the answer. However, to be thorough, I should cover both the basic answer and possible extensions. Also, check if there's any ambiguity. For instance, in some contexts, 5x5 could refer to dimensions, like a 5x5 square, which has an area of 25 units. But again, without more context, the safest answer is 25.\n\nI should make sure the response is clear and concise. Start with the answer, then offer further explanation if needed. Maybe ask if they need more details. That way, the user gets the immediate answer and the option to explore more if necessary.\n</think>\n\nThe result of 5 multiplied by 5 is **25**. \n\nIf you meant something else (like a 5x5 grid, matrix, or other context), feel free to clarify! 😊",
|
||||||
|
"timestamp": "2025-10-26T06:49:05.777661+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
data/conversations/archive_20251026_065520_f3077a13.json
Normal file
23
data/conversations/archive_20251026_065520_f3077a13.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_065520_f3077a13",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T06:49:23.714168+00:00",
|
||||||
|
"updated_at": "2025-10-26T06:55:07.152876+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T06:49:23.717948+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "hi there im melvin",
|
||||||
|
"timestamp": "2025-10-26T06:55:03.874222+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user introduced himself as Melvin. I need to respond in a friendly and welcoming manner. Let me start by acknowledging his name and offering help. Maybe ask how I can assist him. Keep it simple and open-ended so he feels comfortable sharing what he needs. Also, make sure to use proper grammar and a friendly tone. Let me put that together.\n</think>\n\nHello, Melvin! Nice to meet you. How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T06:55:07.152876+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
93
data/conversations/archive_20251026_070313_12321467.json
Normal file
93
data/conversations/archive_20251026_070313_12321467.json
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_070313_12321467",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T06:55:20.586134+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:03:10.783874+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T06:55:20.588351+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "hello",
|
||||||
|
"timestamp": "2025-10-26T07:00:14.220192+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user said \"hello\". I need to respond in a friendly and helpful manner. Since this is a new conversation, I should start by greeting them back and offering assistance. Maybe ask how I can help. Keep it simple and open-ended. Let me make sure the tone is positive and approachable. Alright, that should work.\n</think>\n\nHello! How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:00:17.374525+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "hello",
|
||||||
|
"timestamp": "2025-10-26T07:00:21.348214+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user said \"hello\" again. I need to respond appropriately. Since they've already greeted me once, maybe they're testing or just being friendly. I should keep it welcoming but not too repetitive. Let me think of a friendly reply that invites them to ask for help. Maybe add an emoji to keep it light. Alright, \"Hello again! How can I assist you today? 😊\" That sounds good. It's friendly and open-ended.\n</think>\n\nHello again! How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:00:23.823935+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "test without reasoning",
|
||||||
|
"timestamp": "2025-10-26T07:00:45.340645+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just said \"test without reasoning.\" Let me think about how to respond. They might be looking for a quick test or a challenge without needing a detailed explanation. Since they've been saying \"hello\" twice and now this, maybe they're testing the AI's ability to handle different commands or just want a simple interaction.\n\nI should acknowledge their request and offer a straightforward test. Maybe ask them to provide a question or a task they want to test. Keeping it friendly and open-ended would encourage them to specify what they need. Also, since they mentioned \"without reasoning,\" I should avoid overcomplicating the response. Just a simple prompt to get them started. Let me make sure the reply is clear and inviting.\n</think>\n\nSure! What would you like to test? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:00:48.914505+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Test again",
|
||||||
|
"timestamp": "2025-10-26T07:01:06.523902+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user said \"Test again.\" Let me think about how to respond.\n\nFirst, I need to understand what they mean by \"test again.\" They might be referring to a previous test or a different kind of test. Since the conversation started with \"hello\" and then \"test without reasoning,\" maybe they want to continue with a test but without the reasoning part. \n\nIn the previous interaction, the user asked to test without reasoning, and I asked what they wanted to test. Now they're saying \"Test again,\" so they might be looking for another test. But I should clarify to make sure I'm on the same page. \n\nI should respond in a friendly and open manner, asking what specific test they have in mind. That way, I can provide the right kind of assistance. Also, keeping the tone positive and helpful is important. \n\nMaybe they want a different type of test, like a quiz, a logic puzzle, or something else. I need to prompt them to specify so I can help effectively. Let me make sure my response is clear and invites them to explain what they need.\n</think>\n\nSure! What kind of test would you like to take? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:01:11.584794+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "hello",
|
||||||
|
"timestamp": "2025-10-26T07:01:16.187153+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just said \"hello\" again. Let me check the conversation history.\n\nLooking back, the user started with \"hello,\" then repeated it a couple of times. The assistant responded each time with a friendly greeting and an offer to help. Then the user asked for a test, and the assistant asked what kind of test. The user said \"test again,\" and the assistant asked for more details. Now the user is back to \"hello.\"\n\nHmm, maybe the user is testing how the assistant responds to repeated greetings. Or perhaps they're just being polite and want to start a new conversation. Since the user has been asking for tests and then going back to greetings, maybe they're not sure what they want or are experimenting.\n\nThe assistant should keep the tone friendly and open. Since the user might be unsure, offering assistance again makes sense. Maybe the user is looking for help with something specific but hasn't clarified yet. The assistant should stay welcoming and ready to help with whatever the user needs, whether it's a test, a question, or just a chat.\n</think>\n\nHello again! How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:01:21.024279+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Why do you think",
|
||||||
|
"timestamp": "2025-10-26T07:01:29.563091+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user asked \"Why do you think...\" and I need to respond. Let me see. They might be asking for my opinion on something, but the question is incomplete. Maybe they meant to ask why I think something is a certain way. Since the conversation history shows they've been testing and saying hello, perhaps they're just testing how I respond to incomplete questions.\n\nI should acknowledge their question and prompt them to complete it. Keep it friendly and open-ended. Let them know I'm here to help once they provide more details. Make sure the response is encouraging and not too formal. Maybe add an emoji to keep the tone light. Yeah, that should work.\n</think>\n\nHello! It seems like your question is incomplete. Could you clarify what you're asking? I'm here to help! 😊",
|
||||||
|
"timestamp": "2025-10-26T07:01:33.140307+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Test",
|
||||||
|
"timestamp": "2025-10-26T07:02:45.642647+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just said \"Test.\" Let me think about how to respond.\n\nFirst, the conversation history shows that the user has been asking for tests multiple times. They started with \"hello,\" then \"test without reasoning,\" \"Test again,\" and now \"Test.\" The assistant has been responding with prompts for more details each time. \n\nThe user might be looking for a specific type of test, but they haven't provided enough information. They could be testing the assistant's ability to handle repeated requests or maybe they're trying to see how the assistant responds to different inputs. \n\nSince the user hasn't specified the type of test, the assistant should ask for clarification. However, the user might be expecting a test in the form of a quiz or a challenge. Maybe they want to see if the assistant can generate a test or answer questions. \n\nThe assistant should remain helpful and open-ended. It's important to prompt the user to provide more details so that the assistant can offer the best possible assistance. The previous responses have been friendly and encouraging, so maintaining that tone is key. \n\nI should respond by asking the user to specify the type of test they're interested in. That way, the assistant can tailor the response appropriately. It's also good to keep the conversation engaging and positive.\n</think>\n\nSure! What type of test would you like to take? 😊 (e.g., a quiz, a logic puzzle, a trivia game, or something else?)",
|
||||||
|
"timestamp": "2025-10-26T07:02:51.221636+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Test",
|
||||||
|
"timestamp": "2025-10-26T07:03:04.498780+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just said \"Test\" again. Let me check the conversation history to understand the context better.\n\nThe user started with \"hello,\" then repeated it a few times. They asked \"Why do you think\" and then \"Test\" again. The assistant has been responding with prompts to clarify what kind of test they want. Now, the user is just saying \"Test\" again, so they might be expecting a specific test or quiz. \n\nSince the user hasn't provided any details about the type of test, the assistant should continue to ask for clarification. Maybe the user is testing the assistant's ability to handle repeated prompts or is looking for a specific kind of test they haven't mentioned yet. \n\nI need to make sure the response is friendly and encourages the user to specify the type of test they want. It's possible they might be unsure themselves or just want to see the assistant's response. \n\nI should keep the tone positive and open-ended, offering examples of different tests to guide them. That way, the user can choose what they're interested in. \n\nAlso, considering the previous interactions, the user might be testing the assistant's patience or just looking for a simple response. So, the assistant should remain helpful and not get frustrated. \n\nIn summary, the best approach is to ask the user to specify the type of test they want, providing examples to help them decide. This keeps the conversation moving forward and ensures the user gets the assistance they need.\n</think>\n\nSure! What type of test would you like to take? 😊 (e.g., a quiz, a logic puzzle, a trivia game, or something else?)",
|
||||||
|
"timestamp": "2025-10-26T07:03:10.783874+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
43
data/conversations/archive_20251026_070439_8a492c00.json
Normal file
43
data/conversations/archive_20251026_070439_8a492c00.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_070439_8a492c00",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:03:13.346566+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:04:31.711614+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:03:13.350732+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Think",
|
||||||
|
"timestamp": "2025-10-26T07:03:21.818846+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just said \"Think.\" I need to figure out how to respond appropriately. Since they didn't provide a specific question or topic, I should prompt them to clarify what they need help with. Maybe they're trying to get me to think about something, but without more context, it's hard to know. I should keep the response friendly and open-ended to encourage them to provide more details. Let me make sure I'm not making assumptions and that I'm inviting them to specify their request.\n</think>\n\nSure! Could you clarify what you're thinking about or what you'd like help with? I'm here to assist with any questions or topics you'd like to explore. 😊",
|
||||||
|
"timestamp": "2025-10-26T07:03:24.598739+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Dont think",
|
||||||
|
"timestamp": "2025-10-26T07:03:28.434716+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user said \"Dont think\" after I asked them to clarify what they were thinking about. They might be indicating they don't want to think about something or are avoiding the topic. I need to respond in a way that's supportive and not pushy.\n\nFirst, I should acknowledge their response without forcing them to explain. Maybe offer alternative ways to help. Let them know I'm here if they're ready to talk. Keep the tone friendly and open-ended. Avoid any pressure to elaborate. Maybe use an emoji to keep it light. Let them know I'm available when they're comfortable. Make sure the response is concise and not too long. Check for any possible misunderstandings. Ensure the reply is encouraging and non-judgmental. Maybe suggest they can ask anything they're curious about. Keep the door open for them to reach out again. Avoid any assumptions about their feelings or thoughts. Just provide a safe space for them to engage if they want.\n</think>\n\nNo problem! If you ever feel ready to chat, think, or explore something, I'm here to help. You've got this! 😊 Let me know how I can support you.",
|
||||||
|
"timestamp": "2025-10-26T07:03:32.966569+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "test",
|
||||||
|
"timestamp": "2025-10-26T07:04:26.514192+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just said \"test\". That's pretty vague. I need to figure out what they're trying to test. Maybe they want to see how I respond to simple commands or if I can handle unexpected inputs.\n\nFirst, I should acknowledge their message. \"Test\" could be a way to check if I'm active or if I can process their input. I should respond in a friendly and open manner to encourage them to elaborate.\n\nI should keep the response light and inviting. Maybe add an emoji to keep the tone positive. Let them know I'm here to help with any specific tests they have in mind. That way, they feel supported and are more likely to provide more details if needed.\n\nAlso, since the conversation started with \"Think\" and then \"Dont think\", maybe they're testing different modes or responses. I should stay flexible and ready to assist regardless of their intent. Just make sure the reply is encouraging and open-ended.\n</think>\n\nHey there! 😊 What kind of test are you thinking of? Whether it's a quick question, a fun challenge, or something else, I'm here to help! Let me know how I can assist. 🚀",
|
||||||
|
"timestamp": "2025-10-26T07:04:31.711614+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
53
data/conversations/archive_20251026_070825_37517bf3.json
Normal file
53
data/conversations/archive_20251026_070825_37517bf3.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_070825_37517bf3",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:04:39.162007+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:08:22.567377+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:04:39.165911+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello",
|
||||||
|
"timestamp": "2025-10-26T07:05:56.033901+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user said \"Hello\". I need to respond appropriately. Since it's a new conversation, I should greet them back and offer assistance. Let me keep it friendly and open-ended. Maybe ask how I can help them today. That should set a good tone for the interaction.\n</think>\n\nHello! How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:05:57.548688+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "TEst",
|
||||||
|
"timestamp": "2025-10-26T07:06:00.333836+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just sent \"TEst\". Let me think about how to respond. They might be testing if I'm active or if I can handle typos. Since the previous message was \"Hello\" and the response was friendly, maybe they want to continue the conversation.\n\nI should acknowledge their message in a friendly way. Maybe point out the typo and offer help. That way, I'm being helpful and also showing I'm attentive. Let me check if there's anything else they might need. They could be starting a new topic, so keeping the door open for further questions is good. Keep the tone positive and approachable. Alright, let's go with that.\n</think>\n\nLooks like you might have meant to type something else! 😊 Let me know how I can assist you—whether it's answering questions, helping with tasks, or just chatting. I'm here for you! 🌟",
|
||||||
|
"timestamp": "2025-10-26T07:06:03.951031+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Test",
|
||||||
|
"timestamp": "2025-10-26T07:06:10.689911+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just typed \"Test\" again. They might be testing the system or checking if I respond. I should acknowledge their message in a friendly way. Maybe add an emoji to keep it light. Let me make sure to invite them to ask anything they need help with. Keep the tone positive and helpful.\n</think>\n\nHello again! 😊 It looks like you're testing me, but I'm here to help with anything you need—whether it's answering questions, solving problems, or just having a chat. What can I assist you with today? 🌟",
|
||||||
|
"timestamp": "2025-10-26T07:06:13.042301+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Test",
|
||||||
|
"timestamp": "2025-10-26T07:08:20.033166+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user keeps sending \"Test\" messages. Maybe they're checking if I'm responsive or testing my replies. I should acknowledge their messages in a friendly way. Let me make sure to keep the tone positive and open for them to ask for help. I'll respond with a cheerful message encouraging them to ask anything they need.\n</think>\n\nHello again! 😊 It seems like you're just testing me, but I'm here to help with anything you need—whether it's answering questions, solving problems, or just having a chat. What can I assist you with today? 🌟",
|
||||||
|
"timestamp": "2025-10-26T07:08:22.567377+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
data/conversations/archive_20251026_071216_a2983057.json
Normal file
33
data/conversations/archive_20251026_071216_a2983057.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_071216_a2983057",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:08:25.864839+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:08:58.830972+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:08:25.867211+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello there",
|
||||||
|
"timestamp": "2025-10-26T07:08:27.864602+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\n</think>\n\nHello! How can I assist you today?",
|
||||||
|
"timestamp": "2025-10-26T07:08:28.248345+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What is the purpose of UNIX when UNIX-like works fine?",
|
||||||
|
"timestamp": "2025-10-26T07:08:51.440826+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user is asking about the purpose of UNIX when UNIX-like systems work fine. Let me break this down. First, I need to understand what they mean by UNIX-like. They probably know that systems like Linux and macOS are UNIX-like, meaning they follow the UNIX philosophy but aren't actual UNIX systems.\n\nSo, why would someone still use UNIX? Maybe they're confused about the difference between UNIX and UNIX-like. I should explain that UNIX is a specific operating system developed by AT&T, while UNIX-like refers to systems that emulate UNIX's design principles. \n\nThe user might be thinking that since UNIX-like systems are functional, why bother with the original UNIX. I should highlight the historical context, like UNIX's role in developing modern operating systems. Also, mention that UNIX is still used in certain environments, like servers or specific applications, and that it's recognized by the IEEE for compliance.\n\nThey might also be interested in the standards and certifications. UNIX has strict standards, so systems that are certified as UNIX have certain guarantees. This could be important for enterprises or specific software that requires UNIX compliance.\n\nI should also note that while UNIX-like systems are more common and user-friendly, UNIX itself has its own niche. Maybe the user is looking for reasons why someone would prefer the original UNIX over its derivatives. I need to make sure the answer is clear, covering the distinction, historical significance, and current uses of UNIX.\n</think>\n\nUNIX is a specific operating system developed by AT&T in the 1970s, known for its robust design, portability, and influence on modern computing. While UNIX-like systems (e.g., Linux, macOS) emulate UNIX's design principles, they are not officially UNIX. UNIX remains significant for its adherence to strict standards (e.g., IEEE POSIX), certification, and use in specialized environments (e.g., servers, embedded systems). Its legacy ensures compatibility, reliability, and a foundation for innovation in operating systems.",
|
||||||
|
"timestamp": "2025-10-26T07:08:58.830972+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
data/conversations/archive_20251026_071416_41dd825d.json
Normal file
33
data/conversations/archive_20251026_071416_41dd825d.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_071416_41dd825d",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:12:16.271041+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:12:39.631264+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:12:16.275208+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What can you do?\\",
|
||||||
|
"timestamp": "2025-10-26T07:12:21.247841+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user asked, \"What can you do?\" I need to explain my capabilities clearly. Let me start by outlining the main functions. First, I can answer questions on a wide range of topics. Then, I can help with writing tasks like essays or stories. Also, I can assist with coding and programming problems. Oh, and I can translate languages. Maybe I should mention creating content like articles or social media posts. Oh, and I can help with problem-solving in various fields. Wait, I should also include things like brainstorming ideas or providing explanations. Let me make sure I cover all the key areas without making it too long. Need to keep it friendly and approachable. Alright, that should cover it.\n</think>\n\nI can assist with a wide range of tasks and topics! Here's what I can do:\n\n1. **Answer Questions**: Provide explanations, facts, or insights on almost any subject (science, history, technology, culture, etc.).\n2. **Creative Writing**: Help draft stories, poems, scripts, or even social media content.\n3. **Problem Solving**: Work through math problems, logic puzzles, or coding challenges.\n4. **Language Translation**: Translate text between languages (e.g., English to Spanish, Chinese to French).\n5. **Code Generation/Debugging**: Write or fix code in languages like Python, JavaScript, Java, etc.\n6. **Research Assistance**: Summarize articles, analyze data, or help with academic research.\n7. **Brainstorming**: Generate ideas for projects, business plans, or creative concepts.\n8. **Explanations**: Break down complex topics into simpler terms (e.g., explaining quantum physics or machine learning).\n9. **Personalized Help**: Tailor responses to your specific needs, whether it's learning a skill or planning a trip.\n\nLet me know what you need help with! 😊",
|
||||||
|
"timestamp": "2025-10-26T07:12:28.563768+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Again",
|
||||||
|
"timestamp": "2025-10-26T07:12:33.430733+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just said \"Again.\" Hmm, they might be referring to something I mentioned earlier. Let me check the conversation history.\n\nThe user first asked, \"What can you do?\" and I listed all the capabilities. Then they replied with \"Again.\" Maybe they want to go over the list again or ask for more details on a specific point. Alternatively, they might be asking for another example or a different task.\n\nI should respond in a friendly way, inviting them to clarify what they need. It's possible they're not sure which service they want or need more examples. I should keep the tone open and helpful, encouraging them to specify their request so I can assist better. Let me make sure to acknowledge their message and offer further help.\n</think>\n\nAh, I see! 😊 If you're looking for a refresher on what I can do, here's a quick recap of my main capabilities: \n\n- **Answer questions** (any topic, from science to pop culture). \n- **Write stories, poems, or scripts** (creative writing). \n- **Solve problems** (math, logic, coding, etc.). \n- **Translate text** between languages. \n- **Generate or debug code** (Python, JavaScript, etc.). \n- **Help with research** or data analysis. \n- **Brainstorm ideas** for projects or plans. \n- **Explain complex topics** in simple terms. \n- **Customize help** to your specific needs. \n\nLet me know what you’d like to explore next! 🌟",
|
||||||
|
"timestamp": "2025-10-26T07:12:39.631264+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
data/conversations/archive_20251026_073127_5e8a9546.json
Normal file
23
data/conversations/archive_20251026_073127_5e8a9546.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_073127_5e8a9546",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:14:16.661307+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:14:22.827962+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:14:16.663958+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Test",
|
||||||
|
"timestamp": "2025-10-26T07:14:20.485642+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>\nOkay, the user just sent \"Test\". I need to respond appropriately. Since it's a simple test, maybe they're checking if I'm active or want to start a conversation. I should acknowledge their message and ask how I can assist them. Keep it friendly and open-ended. Let me make sure the response is clear and invites them to elaborate on what they need help with. Alright, that should cover it.\n</think>\n\nHello! It seems like you're testing me. How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:14:22.827962+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
data/conversations/archive_20251026_073238_0462b871.json
Normal file
33
data/conversations/archive_20251026_073238_0462b871.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_073238_0462b871",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:31:27.416134+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:32:07.012478+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:31:27.418369+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello there!",
|
||||||
|
"timestamp": "2025-10-26T07:31:31.439628+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello! 🌟 How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:31:48.207066+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What can you do?",
|
||||||
|
"timestamp": "2025-10-26T07:32:01.831359+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hi there! 👋 I'm really excited to tell you what I can do! 🚀\n\nI'm here to help with all sorts of things—whether you're looking for:\n\n🔹 **Answering questions** – From science and history to tech and pop culture! \n🔹 **Creative writing** – Help you write stories, poems, scripts, or even emails. \n🔹 **Problem-solving** – Math, logic puzzles, coding challenges, or planning strategies. \n🔹 **Learning & studying** – Summarize topics, explain concepts, or help with homework. \n🔹 **Language practice** – Translate, write in different languages, or improve your skills. \n🔹 **Programming help** – Code suggestions, debugging, or learning new languages. \n🔹 **Daily tasks** – Make to-do lists, organize schedules, or brainstorm ideas. \n🔹 **Emotional support** – Just chat, share thoughts, or talk through anything on your mind (with care and empathy!).\n\nI'm not perfect—no magic wand—but I'm always here to help you think, create, and explore. 💡\n\nSo what would you like to work on today? 😊✨",
|
||||||
|
"timestamp": "2025-10-26T07:32:07.012478+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
53
data/conversations/archive_20251026_073517_b1adec2f.json
Normal file
53
data/conversations/archive_20251026_073517_b1adec2f.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_073517_b1adec2f",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:32:38.950851+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:34:22.390388+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:32:38.955297+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "How are you doing today?",
|
||||||
|
"timestamp": "2025-10-26T07:32:45.446826+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "I'm doing great! 😊 I'm always here to help with whatever you need—whether it's answering questions, brainstorming ideas, or just chatting. How can I assist you today?",
|
||||||
|
"timestamp": "2025-10-26T07:32:53.923968+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What? /think",
|
||||||
|
"timestamp": "2025-10-26T07:33:08.742179+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Ah, I see! 😄 You're probably thinking: *\"What? /think\"* — maybe you meant to say **\"What? I think...\"** or **\"What? / think [something]\"**? \n\nNo worries at all! I'm here to help you clarify. Could you tell me: \n- What you were trying to say? \n- Or what you’d like to *think* about? (Like a question, idea, or just a quick thought?) \n\nJust say the words, and I’ll help you work through it — no pressure, no judgment! 💡 \nWhat’s on your mind? 🌟",
|
||||||
|
"timestamp": "2025-10-26T07:33:18.998440+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello there",
|
||||||
|
"timestamp": "2025-10-26T07:33:55.741794+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello! 😊 So glad you're here—hope your day is bright and full of good vibes. What's on your mind today? I'm all ears! 🌟",
|
||||||
|
"timestamp": "2025-10-26T07:34:04.592844+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Was",
|
||||||
|
"timestamp": "2025-10-26T07:34:12.781334+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Ah, \"Was\" — that’s a fun little fragment! 😄 \nIs this part of a sentence you were thinking of? Like: \n- *\"Was that helpful?\"* \n- *\"Was this what you meant?\"* \n- Or just a quick thought in the middle of something? \n\nNo worries at all — I’m here to help you untangle it! Just say what’s on your mind, and I’ll match you there. 💬✨ \n\nWhat’s the *full* thought behind \"Was\"? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:34:22.390388+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
83
data/conversations/archive_20251026_074254_7e273f2b.json
Normal file
83
data/conversations/archive_20251026_074254_7e273f2b.json
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_074254_7e273f2b",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:35:17.692597+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:42:49.146793+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:35:17.695093+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello",
|
||||||
|
"timestamp": "2025-10-26T07:35:21.460195+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello! How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:35:25.515467+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Ask a questionHello",
|
||||||
|
"timestamp": "2025-10-26T07:35:30.996436+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "What's something you'd like to learn about today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:35:47.215863+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "about python",
|
||||||
|
"timestamp": "2025-10-26T07:35:53.243737+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Great! Python is a super popular programming language that's easy to learn and super versatile. 😊 \n\n**Here's a quick question to help me tailor my answer:** \n> *What aspect of Python are you curious about?* \n*(For example: beginner basics, web development, data science, AI, automation, or something else?)* \n\nJust tell me what interests you, and I’ll give you a clear, friendly explanation! 🐍",
|
||||||
|
"timestamp": "2025-10-26T07:36:02.475419+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "test",
|
||||||
|
"timestamp": "2025-10-26T07:36:07.763701+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hmm, \"test\" is a great start! 😊 \n\nSince you said \"test,\" here are a few possible directions we could go — just pick what interests you most:\n\n1. **Python Testing (like unit testing)** – How to write tests for your code using `unittest` or `pytest`. \n2. **Python Test Environment** – How to set up a testing environment or run scripts. \n3. **Test a Python Script** – Want to see a simple example of running a test? \n4. **\"Test\" as in Python's `test` module** – Like how Python handles testing internally. \n\nOr maybe you just meant \"test\" as in \"try it out\"? \n\n👉 Could you clarify what you're really asking about? I'm here to help! 🚀",
|
||||||
|
"timestamp": "2025-10-26T07:36:12.983291+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Think please",
|
||||||
|
"timestamp": "2025-10-26T07:36:18.387358+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Got it! 😄 I'll *think through* something practical for you based on your \"test\" interest and keep it super simple. \n\n**Here’s what I’ll do:** \nI’ll write a **one-liner Python test** you can run *right now* in your terminal (no setup needed). It’s the simplest test possible — and it’ll prove I’m thinking! \n\n```python\nprint(\"Hello, I’m thinking for you!\") # 👀 This is a test!\n```\n\n**Run it** in your terminal: \n```bash\npython -c \"print('Hello, I’m thinking for you!')\"\n```\n\n✅ **Why this works**: \n- It’s a *real* test (it checks if the code runs without errors). \n- It’s *instant* (no dependencies, no installation). \n- It’s *fun* (it literally says \"I’m thinking for you!\"). \n\nYou’re welcome! 🐍✨ \nWhat would you like to try next? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:36:33.374805+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "hello",
|
||||||
|
"timestamp": "2025-10-26T07:38:30.307131+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello again! 😊 It’s great to see you back. \n\nI’m here to help with **any** Python question—whether it’s a quick example, a concept you’re confused about, or just something fun to try. \n\nWhat’s on your mind today? 🐍✨ \n\n*(P.S. You can even say \"test\" again if you want to run another tiny Python experiment!)*",
|
||||||
|
"timestamp": "2025-10-26T07:38:38.807487+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello",
|
||||||
|
"timestamp": "2025-10-26T07:42:41.121466+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello! 🌟 Just a little hello to brighten your day. 😊 \n\nWhat would you like to explore or try today? (Python, a fun fact, a quick code snippet, or just a chat?) I'm all ears! 💬",
|
||||||
|
"timestamp": "2025-10-26T07:42:49.146793+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
data/conversations/archive_20251026_074828_f11452f2.json
Normal file
23
data/conversations/archive_20251026_074828_f11452f2.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_074828_f11452f2",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:42:54.664856+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:43:03.637045+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:42:54.668757+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello",
|
||||||
|
"timestamp": "2025-10-26T07:42:59.073389+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello! How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:43:03.637045+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
43
data/conversations/archive_20251026_075259_179ebacd.json
Normal file
43
data/conversations/archive_20251026_075259_179ebacd.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_075259_179ebacd",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:48:28.269692+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:52:43.226134+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:48:28.271652+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What can you do?",
|
||||||
|
"timestamp": "2025-10-26T07:48:31.685639+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hey there! 👋 I'm Qwen, and I'm really excited to help you out! Here's what I can do for you:\n\n- ✨ **Answer questions** (science, tech, history, you name it!)\n- 📝 **Write stories, emails, scripts, and even poems** - just tell me what you need!\n- 💡 **Do logical reasoning** and solve puzzles\n- 🧠 **Explain complex topics** in simple terms\n- 💻 **Write and debug code** in many programming languages\n- 🌍 **Translate languages** (I know a lot of them!)\n- 🎲 **Play games** with you (riddles, word games, etc.)\n- 🤔 **Brainstorm ideas** for projects, businesses, or creative stuff\n\nI'm here to help with anything you need! What would you like to try today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:48:45.333325+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello tere",
|
||||||
|
"timestamp": "2025-10-26T07:49:06.605433+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello there! 😊 How can I help you today?",
|
||||||
|
"timestamp": "2025-10-26T07:49:18.385029+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Test",
|
||||||
|
"timestamp": "2025-10-26T07:52:37.259353+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Test? 😄 Let me know how you'd like to test me! A question? A story? A game? I'm all ears!",
|
||||||
|
"timestamp": "2025-10-26T07:52:43.226134+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
43
data/conversations/archive_20251026_075756_53b92057.json
Normal file
43
data/conversations/archive_20251026_075756_53b92057.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_075756_53b92057",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:52:59.714612+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:55:49.231096+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:52:59.716843+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "How are you",
|
||||||
|
"timestamp": "2025-10-26T07:53:03.474599+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "I'm doing well, thank you! I'm here to help with any questions or tasks you might have. How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T07:53:10.115769+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello there",
|
||||||
|
"timestamp": "2025-10-26T07:55:19.057072+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello! 😊 It's great to see you again. How can I help you today?",
|
||||||
|
"timestamp": "2025-10-26T07:55:21.666512+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What is the purpose of python",
|
||||||
|
"timestamp": "2025-10-26T07:55:29.257049+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "That's a great question! 😊 **Python's primary purpose** is to be a **versatile, high-level programming language** designed for **readability, simplicity, and productivity**. Here’s a clear breakdown of its key purposes:\n\n---\n\n### 🌟 Core Purposes of Python:\n1. **General-Purpose Programming** \n Python can solve problems across *any* domain: \n → Web development (Django, Flask) \n → Data science & AI (Pandas, TensorFlow, PyTorch) \n → Automation (scripting tasks, file handling) \n → Scientific computing (NumPy, SciPy) \n → Education (easy syntax for beginners) \n → Cloud services (AWS, Google Cloud) \n → And more! \n\n2. **Readability & Simplicity** \n Python uses clean, human-readable syntax (e.g., `print(\"Hello\")` instead of `printf(\"Hello\");`). This makes it: \n → Easy to learn for beginners \n → Faster to write and debug \n → Great for collaborative projects \n\n3. **Community & Ecosystem** \n Python has **millions of libraries** (like `requests` for APIs, `matplotlib` for data visualization) that let developers solve complex problems *without* reinventing the wheel. \n\n4. **Cross-Platform Compatibility** \n Python runs on Windows, macOS, Linux, and even embedded systems — no extra setup needed!\n\n---\n\n### 💡 Why Python Stands Out:\n- **\"Batteries included\"**: Comes with built-in tools for common tasks (e.g., file I/O, networking). \n- **Used by giants**: Google, Netflix, Spotify, NASA, and major tech companies rely on Python. \n- **Not just for beginners**: Professionals use it for AI/ML, big data, and enterprise apps.\n\n---\n\n### 🚫 Common Misconceptions:\n- ❌ *\"Python is only for beginners.\"* \n → **Truth**: It’s used by experts for complex systems (e.g., AI models, financial trading). \n- ❌ *\"Python is slow.\"* \n → **Truth**: It’s fast *for its simplicity* and often outperforms other languages in specific tasks (like data analysis).\n\n---\n\n### In a nutshell: \n> **Python exists to let developers write clear, efficient code quickly — whether they’re building a simple script, a web app, or an AI model.** \n\nIf you’re curious about a specific use case (like \"How do I use Python for data analysis?\" or \"Can Python make websites?\"), just ask! 😊 I’d love to help you dive deeper. What interests you most?",
|
||||||
|
"timestamp": "2025-10-26T07:55:49.231096+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
data/conversations/archive_20251026_080206_48becd70.json
Normal file
23
data/conversations/archive_20251026_080206_48becd70.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_080206_48becd70",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T07:57:56.655186+00:00",
|
||||||
|
"updated_at": "2025-10-26T07:58:54.666964+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T07:57:56.659242+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello there",
|
||||||
|
"timestamp": "2025-10-26T07:58:47.110776+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>Okay, the user said \"Hello there\". That's a simple greeting. I need to respond warmly and professionally. Let me think about the best way to start.\n\nFirst, I should acknowledge their greeting. Maybe say something like \"Hello! I'm glad you're here.\" That's friendly and opens the door for further conversation.\n\nI should check if they need help with anything. People often greet and then have a question or need assistance. So adding a question like \"How can I assist you today?\" would be good.\n\nWait, the user might be testing if I'm a real assistant. I should make sure my response is clear and not too robotic. Avoid using markdown, keep it conversational.\n\nAlso, since it's a new conversation, I don't have any context from previous messages. Just need to be straightforward.\n\nLet me put it all together: \"Hello! I'm glad you're here. How can I assist you today?\" That sounds natural and helpful.\n\nHmm, maybe add a smiley to make it more friendly? Like \"😊\". But the user didn't specify, so maybe better to keep it simple. Wait, the instructions say not to use markdown, so emojis might be okay if they're plain. Let me check previous examples. In the problem statement, they mentioned not to use markdown, but emojis are usually allowed in text. I'll include a smiley to add warmth.\n\nWait, the user's message is \"Hello there\" which is a bit casual. So a friendly response with a smiley is appropriate.\n\nSo final response: \"Hello! 😊 How can I assist you today?\"\n\nYes, that's good. It's friendly, clear, and invites them to ask for help.\n</think>\nHello! 😊 How can I assist you today?",
|
||||||
|
"timestamp": "2025-10-26T07:58:54.666964+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
data/conversations/archive_20251026_080452_8beb9b71.json
Normal file
23
data/conversations/archive_20251026_080452_8beb9b71.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "archive_20251026_080452_8beb9b71",
|
||||||
|
"title": "",
|
||||||
|
"created_at": "2025-10-26T08:02:06.172327+00:00",
|
||||||
|
"updated_at": "2025-10-26T08:03:07.103894+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T08:02:06.175348+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello there",
|
||||||
|
"timestamp": "2025-10-26T08:03:02.867925+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>Okay, the user said \"Hello there\". That's a simple greeting. I need to respond in a friendly and welcoming way.\n\nFirst, I should acknowledge their greeting. Maybe add a smiley to keep it warm. Let me think... \"Hello! 😊 How can I help you today?\" That sounds good. It's open-ended and invites them to ask questions or share what they need.\n\nWait, the user might be testing if I'm working or just starting a conversation. Since they said \"New conversation started. Previous conversation archived.\", I should make sure my response is clear and not too robotic.\n\nI should avoid any markdown formatting as per the instructions. Just plain text with a smiley. Let me check: \"Hello! 😊 How can I help you today?\" Yes, that's straightforward and friendly.\n\nIs there anything else they might need? Maybe they want to know about my capabilities, but since they just said hello, it's better to keep it simple. Don't overload them with info right away.\n\nI think that's a good response. Let me write it out.\n</think>\nHello! 😊 How can I help you today?",
|
||||||
|
"timestamp": "2025-10-26T08:03:07.103894+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
22
data/conversations/default.json
Normal file
22
data/conversations/default.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "default",
|
||||||
|
"created_at": "2025-10-26T08:04:52.666706+00:00",
|
||||||
|
"updated_at": "2025-10-26T08:05:01.598962+00:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "New conversation started. Previous conversation archived.",
|
||||||
|
"timestamp": "2025-10-26T08:04:52.669101+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello there",
|
||||||
|
"timestamp": "2025-10-26T08:04:54.458503+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "<think>Okay, the user said \"Hello there\". That's a friendly greeting. I should respond in a warm and welcoming way. Let me think about how to phrase it.\n\nFirst, I want to acknowledge their greeting. Maybe something like \"Hello! 👋 How can I assist you today?\" That's friendly and open-ended. Adding an emoji might make it more approachable.\n\nWait, the user might be testing if I'm responsive. Since it's a new conversation, I should make sure to be clear and helpful. Let me check if there's any specific context I need to consider. The previous conversation is archived, so no prior context.\n\nI should avoid any technical jargon. Keep it simple and conversational. Maybe add a question to prompt them to tell me what they need. Like \"What would you like to discuss or work on today?\" Hmm, but the user might not know what they need yet. The first response should be open-ended.\n\nLet me see examples. In many chatbots, they start with a greeting and a question. For example: \"Hi there! How can I help you today?\" That's good. Adding a smiley emoji could be nice. Let me go with \"Hello! 😊 How can I assist you today?\"\n\nWait, the user said \"Hello there\", so maybe a slightly more casual tone. \"Hello there! 👋 How can I help you today?\" Yes, that's good. It's friendly and matches their phrasing.\n\nI should avoid being too formal. Keep it natural. Let me make sure there's no typo. \"Hello there!\" with an exclamation mark. The emoji is a smiley, which is common in such contexts.\n\nI think that's a good response. It's warm, acknowledges their greeting, and invites them to share what they need. No need for extra information since it's the first message. Alright, that should work.\n</think>\nHello there! 👋 How can I assist you today? 😊",
|
||||||
|
"timestamp": "2025-10-26T08:05:01.598962+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from .quickcenter import QuickCenter
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"QuickCenter",
|
|
||||||
]
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
from ignis import widgets
|
|
||||||
from ignis.window_manager import WindowManager
|
|
||||||
from ignis.services.notifications import NotificationService
|
|
||||||
from modules.m3components import Button
|
|
||||||
from .widgets import NotificationCenter, QuickSliders
|
|
||||||
from user_settings import user_settings
|
|
||||||
from ignis.services.niri import NiriService
|
|
||||||
|
|
||||||
window_manager = WindowManager.get_default()
|
|
||||||
notifications = NotificationService.get_default()
|
|
||||||
|
|
||||||
|
|
||||||
class QuickCenter(widgets.RevealerWindow):
|
|
||||||
def open_window(self, window):
|
|
||||||
window_manager.close_window("QuickCenter")
|
|
||||||
window_manager.open_window(window)
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
notification_center = NotificationCenter()
|
|
||||||
quick_sliders = QuickSliders()
|
|
||||||
bottom_controls = widgets.Box(
|
|
||||||
css_classes=["bottom-controls"],
|
|
||||||
hexpand=True,
|
|
||||||
halign="fill",
|
|
||||||
homogeneous=False,
|
|
||||||
spacing=5,
|
|
||||||
child=[
|
|
||||||
Button.button(
|
|
||||||
icon="power_settings_new",
|
|
||||||
halign="start",
|
|
||||||
hexpand=False,
|
|
||||||
on_click=lambda x: self.open_window("PowerMenu"),
|
|
||||||
vexpand=False,
|
|
||||||
valign="center",
|
|
||||||
size="xs",
|
|
||||||
),
|
|
||||||
Button.button(
|
|
||||||
icon="settings",
|
|
||||||
halign="start",
|
|
||||||
hexpand=False,
|
|
||||||
on_click=lambda x: self.open_window("Settings"),
|
|
||||||
vexpand=False,
|
|
||||||
valign="center",
|
|
||||||
size="xs",
|
|
||||||
),
|
|
||||||
Button.button(
|
|
||||||
icon="clear_all",
|
|
||||||
label="Clear all",
|
|
||||||
halign="end",
|
|
||||||
hexpand=True,
|
|
||||||
on_click=lambda x: notifications.clear_all(),
|
|
||||||
css_classes=["notification-clear-all"],
|
|
||||||
vexpand=True,
|
|
||||||
valign="center",
|
|
||||||
size="xs",
|
|
||||||
visible=notifications.bind(
|
|
||||||
"notifications", lambda value: len(value) != 0
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.content_box = widgets.Box(
|
|
||||||
vertical=True,
|
|
||||||
spacing=0,
|
|
||||||
hexpand=False,
|
|
||||||
css_classes=["quick-center"],
|
|
||||||
child=[notification_center, quick_sliders, bottom_controls],
|
|
||||||
)
|
|
||||||
self.content_box.width_request = 400
|
|
||||||
|
|
||||||
revealer = widgets.Revealer(
|
|
||||||
child=self.content_box,
|
|
||||||
transition_duration=300,
|
|
||||||
)
|
|
||||||
|
|
||||||
close_button = widgets.Button(
|
|
||||||
vexpand=True,
|
|
||||||
hexpand=True,
|
|
||||||
can_focus=False,
|
|
||||||
on_click=lambda x: window_manager.close_window("QuickCenter"),
|
|
||||||
)
|
|
||||||
|
|
||||||
main_overlay = widgets.Overlay(
|
|
||||||
css_classes=["popup-close"],
|
|
||||||
child=close_button,
|
|
||||||
overlays=[revealer],
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
revealer=revealer,
|
|
||||||
child=main_overlay,
|
|
||||||
css_classes=["popup-close"],
|
|
||||||
hide_on_close=True,
|
|
||||||
visible=False,
|
|
||||||
namespace="QuickCenter",
|
|
||||||
popup=True,
|
|
||||||
layer="overlay",
|
|
||||||
kb_mode="exclusive",
|
|
||||||
anchor=["left", "right", "top", "bottom"],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.window_manager = window_manager
|
|
||||||
self.notification_center = notification_center
|
|
||||||
self.revealer = revealer
|
|
||||||
self.actual_content_box = revealer
|
|
||||||
|
|
||||||
self.niri = NiriService.get_default()
|
|
||||||
self.connect("notify::visible", self._toggle_revealer)
|
|
||||||
self.update_side()
|
|
||||||
|
|
||||||
def _toggle_revealer(self, *_):
|
|
||||||
self.revealer.reveal_child = self.visible
|
|
||||||
|
|
||||||
def update_side(self):
|
|
||||||
position = user_settings.interface.modules
|
|
||||||
location = position.location.systeminfotray
|
|
||||||
bar = (
|
|
||||||
user_settings.interface.bar
|
|
||||||
if position.bar_id.systeminfotray == 0
|
|
||||||
else user_settings.interface.bar
|
|
||||||
)
|
|
||||||
side = bar.side
|
|
||||||
if side in ["left", "right"]:
|
|
||||||
self.actual_content_box.set_halign("start" if side == "left" else "end")
|
|
||||||
self.actual_content_box.anchor = ["top", "bottom", side]
|
|
||||||
else:
|
|
||||||
if location == "center":
|
|
||||||
self.actual_content_box.set_halign("center")
|
|
||||||
self.actual_content_box.anchor = ["top", "bottom"]
|
|
||||||
else:
|
|
||||||
self.actual_content_box.set_halign("start" if location == 0 else "end")
|
|
||||||
self.actual_content_box.anchor = [
|
|
||||||
"top",
|
|
||||||
"bottom",
|
|
||||||
"left" if location == 0 else "end",
|
|
||||||
]
|
|
||||||
|
|
||||||
self.revealer.transition_type = "none"
|
|
||||||
if self.niri and self.niri.is_available:
|
|
||||||
self.revealer.transition_type = (
|
|
||||||
"slide_right"
|
|
||||||
if self.actual_content_box.halign == "start"
|
|
||||||
else "slide_left"
|
|
||||||
)
|
|
||||||
self.content_box.set_halign(
|
|
||||||
"end" if self.actual_content_box.halign == "start" else "end"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.actual_content_box.queue_resize()
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from .notificationcenter import NotificationCenter
|
|
||||||
from .sliders import QuickSliders
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"NotificationCenter",
|
|
||||||
"QuickSliders"
|
|
||||||
]
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
from ignis import widgets, utils
|
|
||||||
from ignis.services.notifications import Notification, NotificationService
|
|
||||||
from ignis.window_manager import WindowManager
|
|
||||||
from gi.repository import GLib, Gtk
|
|
||||||
from ...notifications import ExoNotification
|
|
||||||
|
|
||||||
notifications = NotificationService.get_default()
|
|
||||||
window_manager = WindowManager.get_default()
|
|
||||||
|
|
||||||
class Popup(widgets.Revealer):
|
|
||||||
def __init__(self, notification: Notification, **kwargs):
|
|
||||||
widget = ExoNotification(notification)
|
|
||||||
super().__init__(child=widget, transition_type="slide_down", **kwargs)
|
|
||||||
|
|
||||||
notification.connect("closed", lambda x: self.destroy())
|
|
||||||
|
|
||||||
def destroy(self):
|
|
||||||
self.reveal_child = False
|
|
||||||
utils.Timeout(self.transition_duration, self.unparent)
|
|
||||||
|
|
||||||
|
|
||||||
class Notifications(widgets.Box):
|
|
||||||
def __init__(self):
|
|
||||||
loading_notifications_label = widgets.Label(
|
|
||||||
label="Loading notifications...",
|
|
||||||
valign="center",
|
|
||||||
vexpand=True,
|
|
||||||
css_classes=["notification-center-info-label"],
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
vertical=True,
|
|
||||||
child=[loading_notifications_label],
|
|
||||||
vexpand=True,
|
|
||||||
css_classes=["notification-center-content"],
|
|
||||||
spacing=2,
|
|
||||||
setup=lambda self: notifications.connect(
|
|
||||||
"notified",
|
|
||||||
lambda x, notification: self.__on_notified(notification),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
utils.ThreadTask(
|
|
||||||
self.__load_notifications,
|
|
||||||
lambda result: self.set_child(result),
|
|
||||||
).run()
|
|
||||||
|
|
||||||
def __on_notified(self, notification: Notification) -> None:
|
|
||||||
notify = Popup(notification)
|
|
||||||
self.prepend(notify)
|
|
||||||
notify.reveal_child = True
|
|
||||||
|
|
||||||
def __load_notifications(self) -> list[widgets.Label | Popup]:
|
|
||||||
contents: list[widgets.Label | Popup] = []
|
|
||||||
for i in reversed(notifications.notifications):
|
|
||||||
GLib.idle_add(lambda i=i: contents.append(Popup(i, reveal_child=True)))
|
|
||||||
|
|
||||||
contents.append(
|
|
||||||
widgets.Label(
|
|
||||||
label="notifications_off",
|
|
||||||
valign="end",
|
|
||||||
vexpand=True,
|
|
||||||
css_classes=["notification-center-info-icon"],
|
|
||||||
visible=notifications.bind(
|
|
||||||
"notifications", lambda value: len(value) == 0
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
contents.append(
|
|
||||||
widgets.Label(
|
|
||||||
label="No notifications",
|
|
||||||
valign="start",
|
|
||||||
vexpand=True,
|
|
||||||
css_classes=["notification-center-info-label"],
|
|
||||||
visible=notifications.bind(
|
|
||||||
"notifications", lambda value: len(value) == 0
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return contents
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationCenter(widgets.Box):
|
|
||||||
__gtype_name__ = "NotificationCenter"
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
scroll = widgets.Scroll(child=Notifications(), vexpand=True)
|
|
||||||
scroll.set_overflow(Gtk.Overflow.HIDDEN)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
vertical=True,
|
|
||||||
vexpand=True,
|
|
||||||
css_classes=["notification-center"],
|
|
||||||
spacing=10,
|
|
||||||
child=[
|
|
||||||
scroll,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from gi.repository import GLib
|
|
||||||
from ignis import widgets
|
|
||||||
from ignis.services.audio import AudioService
|
|
||||||
from ignis.services.backlight import BacklightService
|
|
||||||
from ignis.window_manager import WindowManager
|
|
||||||
|
|
||||||
audio = AudioService.get_default()
|
|
||||||
backlight = BacklightService.get_default()
|
|
||||||
window_manager = WindowManager.get_default()
|
|
||||||
|
|
||||||
|
|
||||||
class QuickSliders(widgets.Box):
|
|
||||||
def __init__(self):
|
|
||||||
children = []
|
|
||||||
if audio.speaker:
|
|
||||||
self.volume_slider = widgets.Scale(
|
|
||||||
min=0,
|
|
||||||
max=100,
|
|
||||||
step=1.0,
|
|
||||||
on_change=self.on_volume_changed,
|
|
||||||
hexpand=True,
|
|
||||||
)
|
|
||||||
volume_box = widgets.Box(
|
|
||||||
css_classes=["m3-slider"],
|
|
||||||
child=[
|
|
||||||
widgets.Label(label="volume_up", css_classes=["m3-icon"]),
|
|
||||||
self.volume_slider,
|
|
||||||
],
|
|
||||||
spacing=12,
|
|
||||||
)
|
|
||||||
children.append(volume_box)
|
|
||||||
|
|
||||||
if backlight.available:
|
|
||||||
self.backlight_slider = widgets.Scale(
|
|
||||||
min=0,
|
|
||||||
max=100,
|
|
||||||
step=1.0,
|
|
||||||
on_change=self.on_backlight_changed,
|
|
||||||
hexpand=True,
|
|
||||||
)
|
|
||||||
backlight_box = widgets.Box(
|
|
||||||
css_classes=["m3-slider"],
|
|
||||||
child=[
|
|
||||||
widgets.Label(label="brightness_6", css_classes=["m3-icon"]),
|
|
||||||
self.backlight_slider,
|
|
||||||
],
|
|
||||||
spacing=12,
|
|
||||||
)
|
|
||||||
children.append(backlight_box)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
css_classes=["quick-sliders-container"],
|
|
||||||
hexpand=True,
|
|
||||||
halign="fill",
|
|
||||||
spacing=2,
|
|
||||||
vertical=True,
|
|
||||||
child=children,
|
|
||||||
)
|
|
||||||
|
|
||||||
if audio.speaker:
|
|
||||||
audio.speaker.connect("notify::volume", self._on_volume_changed)
|
|
||||||
audio.speaker.connect("notify::is-muted", self._on_volume_changed)
|
|
||||||
if backlight.available:
|
|
||||||
backlight.connect("notify::brightness", self._on_brightness_changed)
|
|
||||||
|
|
||||||
def _on_volume_changed(self, stream, *_):
|
|
||||||
if stream.is_muted:
|
|
||||||
self.volume_slider.set_value(0)
|
|
||||||
else:
|
|
||||||
self.volume_slider.set_value(stream.volume)
|
|
||||||
|
|
||||||
def _on_brightness_changed(self, backlight, *_):
|
|
||||||
self.backlight_slider.set_value(
|
|
||||||
(backlight.brightness / backlight.max_brightness) * 100
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_volume_changed(self, slider):
|
|
||||||
value = slider.get_value()
|
|
||||||
self.set_suppress_osd_flag()
|
|
||||||
audio.speaker.volume = value
|
|
||||||
|
|
||||||
def on_backlight_changed(self, slider):
|
|
||||||
value = slider.get_value()
|
|
||||||
self.set_suppress_osd_flag()
|
|
||||||
backlight.brightness = int((value / 100) * backlight.max_brightness)
|
|
||||||
|
|
||||||
def set_suppress_osd_flag(self):
|
|
||||||
window_manager.suppress_osd = True
|
|
||||||
asyncio.create_task(self.reset_suppress_osd_flag())
|
|
||||||
|
|
||||||
async def reset_suppress_osd_flag(self):
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
window_manager.suppress_osd = False
|
|
||||||
78
main.py
78
main.py
@@ -1,78 +0,0 @@
|
|||||||
"""Entry point for the AI sidebar application."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import gi
|
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
from gi.repository import Gtk # noqa: E402
|
|
||||||
|
|
||||||
from sidebar_window import SidebarWindow # noqa: E402
|
|
||||||
|
|
||||||
HEADLESS_ENV_VAR = "AI_SIDEBAR_HEADLESS"
|
|
||||||
_STARTUP_FAILED = False
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
"""Launch the GTK application and return the exit status."""
|
|
||||||
args = argv or sys.argv
|
|
||||||
|
|
||||||
if os.environ.get(HEADLESS_ENV_VAR) == "1":
|
|
||||||
print("Headless mode enabled; skipping GTK startup.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if not Gtk.init_check():
|
|
||||||
print(
|
|
||||||
"Failed to initialize GTK. Ensure a display server is available.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not (os.environ.get("WAYLAND_DISPLAY") or os.environ.get("DISPLAY")):
|
|
||||||
print(
|
|
||||||
"No Wayland or X11 display detected. Launch this app inside a graphical session.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Warn if GDK_BACKEND is set to X11 on Wayland systems
|
|
||||||
if os.environ.get("WAYLAND_DISPLAY") and os.environ.get("GDK_BACKEND") == "x11":
|
|
||||||
print(
|
|
||||||
"Warning: GDK_BACKEND is set to 'x11' but you're on Wayland.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
"GTK4 Layer Shell requires native Wayland. Use './run.sh' instead.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = Gtk.Application(application_id="ai.sidebar")
|
|
||||||
app.connect("activate", _on_activate)
|
|
||||||
status = app.run(args)
|
|
||||||
return 1 if _STARTUP_FAILED else status
|
|
||||||
|
|
||||||
|
|
||||||
def _on_activate(app: Gtk.Application) -> None:
|
|
||||||
"""Create and present the main sidebar window when the app activates."""
|
|
||||||
try:
|
|
||||||
window = SidebarWindow(application=app)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
if "Gtk couldn't be initialized" in str(exc):
|
|
||||||
print(
|
|
||||||
"Failed to initialize GTK. Ensure a display server is available.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
global _STARTUP_FAILED
|
|
||||||
_STARTUP_FAILED = True
|
|
||||||
app.quit()
|
|
||||||
return
|
|
||||||
raise
|
|
||||||
|
|
||||||
window.present()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""UI widgets for chat messages."""
|
|
||||||
|
|
||||||
|
|
||||||
class MessageWidget:
|
|
||||||
"""Placeholder message widget to be filled in with GTK constructs."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
raise NotImplementedError("Message widget not implemented yet.")
|
|
||||||
267
ollama_client.py
267
ollama_client.py
@@ -1,13 +1,11 @@
|
|||||||
"""Client utilities for interacting with the Ollama API."""
|
"""Client utilities for interacting with the Ollama API via direct HTTP calls."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any, Dict, Iterable, Iterator
|
from typing import Any, Dict, Iterable, Iterator
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
try: # pragma: no cover - optional dependency may not be installed in CI
|
from urllib.error import URLError, HTTPError
|
||||||
import ollama
|
|
||||||
except ImportError: # pragma: no cover - fallback path for environments without Ollama
|
|
||||||
ollama = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
|
|
||||||
class OllamaClientError(RuntimeError):
|
class OllamaClientError(RuntimeError):
|
||||||
@@ -15,88 +13,60 @@ class OllamaClientError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class OllamaUnavailableError(OllamaClientError):
|
class OllamaUnavailableError(OllamaClientError):
|
||||||
"""Raised when the Ollama Python SDK is not available."""
|
"""Raised when the Ollama server is not available."""
|
||||||
|
|
||||||
|
|
||||||
class OllamaClient:
|
class OllamaClient:
|
||||||
"""Thin wrapper around the Ollama Python SDK with graceful degradation."""
|
"""HTTP client for interacting with Ollama's REST API."""
|
||||||
|
|
||||||
def __init__(self, host: str | None = None) -> None:
|
def __init__(self, host: str | None = None) -> None:
|
||||||
self._host = host
|
self._host = host or "http://localhost:11434"
|
||||||
self._client = None
|
|
||||||
self._cached_models: list[str] | None = None
|
self._cached_models: list[str] | None = None
|
||||||
self._is_available = False
|
self._is_available = False
|
||||||
|
|
||||||
if ollama is None:
|
# Check connection during initialization without raising exceptions
|
||||||
return
|
self._check_connection()
|
||||||
|
|
||||||
# Try to initialize client and check connection
|
|
||||||
try:
|
|
||||||
if host and hasattr(ollama, "Client"):
|
|
||||||
self._client = ollama.Client(host=host) # type: ignore[call-arg]
|
|
||||||
|
|
||||||
# Test connection by attempting to list models
|
|
||||||
self._check_connection()
|
|
||||||
except Exception:
|
|
||||||
# Silently fail - availability flag remains False
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ helpers
|
# ------------------------------------------------------------------ helpers
|
||||||
def _check_connection(self) -> None:
|
def _check_connection(self) -> None:
|
||||||
"""Check if Ollama is available and update internal flag."""
|
"""Check if Ollama server is reachable and update internal flag."""
|
||||||
if ollama is None:
|
|
||||||
self._is_available = False
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Attempt a simple list call to verify connection
|
req = Request(f"{self._host}/api/tags", method="GET")
|
||||||
self._call_sdk("list") # type: ignore[arg-type]
|
with urlopen(req, timeout=2) as response:
|
||||||
self._is_available = True
|
self._is_available = response.status == 200
|
||||||
except Exception:
|
except (URLError, HTTPError, TimeoutError, Exception):
|
||||||
self._is_available = False
|
self._is_available = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_available(self) -> bool:
|
def is_available(self) -> bool:
|
||||||
|
"""Check if Ollama server is reachable."""
|
||||||
return self._is_available
|
return self._is_available
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default_model(self) -> str | None:
|
def default_model(self) -> str | None:
|
||||||
|
"""Get the first available model."""
|
||||||
models = self.list_models()
|
models = self.list_models()
|
||||||
return models[0] if models else None
|
return models[0] if models else None
|
||||||
|
|
||||||
def list_models(self, force_refresh: bool = False) -> list[str]:
|
def list_models(self, force_refresh: bool = False) -> list[str]:
|
||||||
"""Return the available model names, caching the result for quick reuse."""
|
"""Return the available model names, caching the result for quick reuse."""
|
||||||
if not self.is_available:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if self._cached_models is not None and not force_refresh:
|
if self._cached_models is not None and not force_refresh:
|
||||||
return list(self._cached_models)
|
return list(self._cached_models)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self._call_sdk("list") # type: ignore[arg-type]
|
req = Request(f"{self._host}/api/tags", method="GET")
|
||||||
# Update availability flag on successful call
|
with urlopen(req, timeout=5) as response:
|
||||||
self._is_available = True
|
data = json.loads(response.read().decode())
|
||||||
except OllamaClientError:
|
# Update availability flag on successful call
|
||||||
self._is_available = False
|
self._is_available = True
|
||||||
return []
|
except (URLError, HTTPError, TimeoutError, Exception):
|
||||||
except Exception:
|
# Return empty list instead of raising on connection failure
|
||||||
self._is_available = False
|
self._is_available = False
|
||||||
return []
|
return []
|
||||||
|
|
||||||
models: list[str] = []
|
models: list[str] = []
|
||||||
# Handle both dict responses (old SDK) and Pydantic objects (new SDK)
|
for item in data.get("models", []):
|
||||||
if isinstance(response, dict):
|
name = item.get("name") or item.get("model")
|
||||||
model_list = response.get("models", [])
|
|
||||||
else:
|
|
||||||
# Pydantic object
|
|
||||||
model_list = getattr(response, "models", [])
|
|
||||||
|
|
||||||
for item in model_list:
|
|
||||||
if isinstance(item, dict):
|
|
||||||
name = item.get("name") or item.get("model")
|
|
||||||
else:
|
|
||||||
# Pydantic object
|
|
||||||
name = getattr(item, "name", None) or getattr(item, "model", None)
|
|
||||||
if name:
|
if name:
|
||||||
models.append(name)
|
models.append(name)
|
||||||
|
|
||||||
@@ -109,98 +79,145 @@ class OllamaClient:
|
|||||||
*,
|
*,
|
||||||
model: str,
|
model: str,
|
||||||
messages: Iterable[Dict[str, str]],
|
messages: Iterable[Dict[str, str]],
|
||||||
|
options: Dict[str, Any] | None = None,
|
||||||
) -> dict[str, str] | None:
|
) -> dict[str, str] | None:
|
||||||
"""Execute a blocking chat call against Ollama."""
|
"""Execute a blocking chat call against Ollama."""
|
||||||
if not self.is_available:
|
# Return error message instead of raising when unavailable
|
||||||
if ollama is None:
|
if not self._is_available:
|
||||||
return {
|
return {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "Ollama SDK is not installed; install `ollama` to enable responses.",
|
"content": "Ollama is not running. Start Ollama with: ollama serve",
|
||||||
}
|
}
|
||||||
else:
|
|
||||||
return {
|
payload = {
|
||||||
"role": "assistant",
|
"model": model,
|
||||||
"content": "Ollama is not running. Start Ollama with: ollama serve",
|
"messages": list(messages),
|
||||||
}
|
"stream": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add options if provided
|
||||||
|
if options:
|
||||||
|
payload["options"] = options
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self._call_sdk(
|
req = Request(
|
||||||
"chat",
|
f"{self._host}/api/chat",
|
||||||
model=model,
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
messages=list(messages),
|
headers={"Content-Type": "application/json"},
|
||||||
stream=False,
|
method="POST",
|
||||||
)
|
)
|
||||||
# Update availability flag on successful call
|
with urlopen(req, timeout=120) as response:
|
||||||
self._is_available = True
|
result = json.loads(response.read().decode())
|
||||||
except OllamaClientError as exc:
|
# Update availability flag on successful call
|
||||||
self._is_available = False
|
self._is_available = True
|
||||||
return {
|
except (URLError, HTTPError, TimeoutError, Exception) as exc:
|
||||||
"role": "assistant",
|
# Update availability flag and return error message
|
||||||
"content": f"Unable to reach Ollama: {exc}\n\nStart Ollama with: ollama serve",
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
self._is_available = False
|
self._is_available = False
|
||||||
return {
|
return {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": f"Unable to reach Ollama: {exc}\n\nStart Ollama with: ollama serve",
|
"content": f"Unable to reach Ollama: {exc}\n\nStart Ollama with: ollama serve",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Handle both dict responses (old SDK) and Pydantic objects (new SDK)
|
# Parse the response
|
||||||
if isinstance(result, dict):
|
message = result.get("message")
|
||||||
message = result.get("message")
|
if not message:
|
||||||
if not message:
|
return {"role": "assistant", "content": ""}
|
||||||
return {"role": "assistant", "content": ""}
|
|
||||||
role = message.get("role") or "assistant"
|
role = message.get("role", "assistant")
|
||||||
content = message.get("content") or ""
|
content = message.get("content", "")
|
||||||
else:
|
|
||||||
# Pydantic object (ollama SDK >= 0.4.0)
|
|
||||||
message = getattr(result, "message", None)
|
|
||||||
if not message:
|
|
||||||
return {"role": "assistant", "content": ""}
|
|
||||||
role = getattr(message, "role", "assistant")
|
|
||||||
content = getattr(message, "content", "")
|
|
||||||
|
|
||||||
return {"role": role, "content": content}
|
return {"role": role, "content": content}
|
||||||
|
|
||||||
def stream_chat(
|
def stream_chat(
|
||||||
self, *, model: str, messages: Iterable[Dict[str, str]]
|
self, *, model: str, messages: Iterable[Dict[str, str]], options: Dict[str, Any] | None = None
|
||||||
) -> Iterator[dict[str, Any]]:
|
) -> Iterator[dict[str, Any]]:
|
||||||
"""Placeholder that exposes the streaming API for future UI hooks."""
|
"""Execute a streaming chat call against Ollama.
|
||||||
if not self.is_available:
|
|
||||||
raise OllamaUnavailableError(
|
Yields dictionaries containing token data from the streaming response.
|
||||||
"Streaming requires the Ollama Python SDK to be installed."
|
Each yielded dict may contain 'message' with 'content' field for tokens.
|
||||||
)
|
"""
|
||||||
|
# Return error message instead of raising when unavailable
|
||||||
|
if not self._is_available:
|
||||||
|
yield {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Ollama is not running. Start Ollama with: ollama serve",
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": list(messages),
|
||||||
|
"stream": True,
|
||||||
|
"think": True, # Enable thinking output for thinking models
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add options if provided
|
||||||
|
if options:
|
||||||
|
payload["options"] = options
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stream = self._call_sdk(
|
req = Request(
|
||||||
"chat",
|
f"{self._host}/api/chat",
|
||||||
model=model,
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
messages=list(messages),
|
headers={"Content-Type": "application/json"},
|
||||||
stream=True,
|
method="POST",
|
||||||
)
|
)
|
||||||
except OllamaClientError as exc:
|
|
||||||
raise OllamaClientError(f"Failed to start streaming chat: {exc}") from exc
|
with urlopen(req, timeout=120) as response:
|
||||||
|
# Update availability flag on successful connection
|
||||||
|
self._is_available = True
|
||||||
|
|
||||||
|
# Read streaming response line by line
|
||||||
|
for line in response:
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
chunk = json.loads(line.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
if not hasattr(stream, "__iter__"):
|
|
||||||
raise OllamaClientError("Ollama returned a non-iterable stream response.")
|
yield chunk
|
||||||
return iter(stream)
|
|
||||||
|
# Check if streaming is complete
|
||||||
|
if chunk.get("done", False):
|
||||||
|
break
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Skip malformed JSON lines
|
||||||
|
continue
|
||||||
|
|
||||||
|
except (URLError, HTTPError, TimeoutError, Exception) as exc:
|
||||||
|
# Update availability flag and yield error message
|
||||||
|
self._is_available = False
|
||||||
|
yield {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": f"Unable to reach Ollama: {exc}\n\nStart Ollama with: ollama serve",
|
||||||
|
"done": True,
|
||||||
|
"error": True,
|
||||||
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------ internals
|
# ------------------------------------------------------------------ internals
|
||||||
def _call_sdk(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
def _make_request(
|
||||||
if not self.is_available:
|
self, endpoint: str, method: str = "GET", data: dict | None = None
|
||||||
raise OllamaUnavailableError(
|
) -> dict:
|
||||||
"Ollama Python SDK is not available in the environment."
|
"""Make an HTTP request to the Ollama API."""
|
||||||
)
|
url = f"{self._host}{endpoint}"
|
||||||
|
|
||||||
target = self._client if self._client is not None else ollama
|
if data:
|
||||||
if target is None or not hasattr(target, method):
|
req = Request(
|
||||||
raise OllamaClientError(
|
url,
|
||||||
f"Ollama SDK does not expose method '{method}'. Install or update the SDK."
|
data=json.dumps(data).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
func = getattr(target, method)
|
req = Request(url, method=method)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return func(*args, **kwargs)
|
with urlopen(req, timeout=30) as response:
|
||||||
except Exception as exc: # pragma: no cover - network errors depend on runtime
|
return json.loads(response.read().decode())
|
||||||
raise OllamaClientError(str(exc)) from exc
|
except (URLError, HTTPError) as exc:
|
||||||
|
raise OllamaClientError(f"Request failed: {exc}") from exc
|
||||||
|
|||||||
103
ollama_monitor.py
Normal file
103
ollama_monitor.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Monitor for tracking Ollama availability and notifying on state changes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
from gi.repository import GLib
|
||||||
|
from .ollama_client import OllamaClient
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaAvailabilityMonitor:
|
||||||
|
"""Monitors Ollama availability and notifies callbacks on state changes."""
|
||||||
|
|
||||||
|
def __init__(self, client: OllamaClient, check_interval_seconds: int = 30) -> None:
|
||||||
|
"""
|
||||||
|
Initialize the availability monitor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: OllamaClient instance to monitor
|
||||||
|
check_interval_seconds: How often to check availability (default: 30s)
|
||||||
|
"""
|
||||||
|
self._client = client
|
||||||
|
self._check_interval_ms = check_interval_seconds * 1000
|
||||||
|
self._callbacks: list[Callable[[bool], None]] = []
|
||||||
|
self._last_state: bool | None = None
|
||||||
|
self._timeout_id: int | None = None
|
||||||
|
self._is_running = False
|
||||||
|
|
||||||
|
def add_callback(self, callback: Callable[[bool], None]) -> None:
|
||||||
|
"""
|
||||||
|
Add a callback to be notified when availability changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Function that takes a bool (True if available, False if not)
|
||||||
|
"""
|
||||||
|
if callback not in self._callbacks:
|
||||||
|
self._callbacks.append(callback)
|
||||||
|
|
||||||
|
def remove_callback(self, callback: Callable[[bool], None]) -> None:
|
||||||
|
"""Remove a previously registered callback."""
|
||||||
|
if callback in self._callbacks:
|
||||||
|
self._callbacks.remove(callback)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start monitoring Ollama availability."""
|
||||||
|
if self._is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._is_running = True
|
||||||
|
# Do an immediate check
|
||||||
|
self._check_availability()
|
||||||
|
# Schedule periodic checks
|
||||||
|
self._timeout_id = GLib.timeout_add(
|
||||||
|
self._check_interval_ms,
|
||||||
|
self._check_availability
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop monitoring Ollama availability."""
|
||||||
|
if not self._is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._is_running = False
|
||||||
|
if self._timeout_id is not None:
|
||||||
|
GLib.source_remove(self._timeout_id)
|
||||||
|
self._timeout_id = None
|
||||||
|
|
||||||
|
def _check_availability(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check current availability and notify callbacks if state changed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True to continue periodic checks, False to stop
|
||||||
|
"""
|
||||||
|
# Force a connection check
|
||||||
|
self._client._check_connection()
|
||||||
|
current_state = self._client.is_available
|
||||||
|
|
||||||
|
# Only notify if state changed
|
||||||
|
if self._last_state is None or self._last_state != current_state:
|
||||||
|
self._last_state = current_state
|
||||||
|
self._notify_callbacks(current_state)
|
||||||
|
|
||||||
|
# Return True to continue periodic checks
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _notify_callbacks(self, is_available: bool) -> None:
|
||||||
|
"""Notify all registered callbacks of the availability state."""
|
||||||
|
for callback in self._callbacks:
|
||||||
|
try:
|
||||||
|
callback(is_available)
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't let one callback failure affect others
|
||||||
|
print(f"Error in availability callback: {e}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Check if the monitor is currently running."""
|
||||||
|
return self._is_running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_state(self) -> bool | None:
|
||||||
|
"""Get the last known availability state (None if never checked)."""
|
||||||
|
return self._last_state
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[tool.black]
|
|
||||||
line-length = 88
|
|
||||||
target-version = ["py313"]
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
line-length = 88
|
|
||||||
target-version = "py313"
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["E", "F", "I"]
|
|
||||||
ignore = []
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
minversion = "8.0"
|
|
||||||
addopts = "-ra"
|
|
||||||
testpaths = ["tests"]
|
|
||||||
127
reasoning_controller.py
Normal file
127
reasoning_controller.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Reasoning mode controller for managing AI reasoning preferences."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PreferencesState:
|
||||||
|
"""User preferences for sidebar behavior."""
|
||||||
|
reasoning_enabled: bool = False
|
||||||
|
default_model: str | None = None
|
||||||
|
theme: str = "default"
|
||||||
|
|
||||||
|
|
||||||
|
class ReasoningController:
|
||||||
|
"""Manages reasoning mode state and model selection."""
|
||||||
|
|
||||||
|
# Model names for reasoning toggle
|
||||||
|
INSTRUCT_MODEL = "hf.co/unsloth/Qwen3-4B-Instruct-2507-GGUF:Q8_K_XL"
|
||||||
|
THINKING_MODEL = "hf.co/unsloth/Qwen3-4B-Thinking-2507-GGUF:Q8_K_XL"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the reasoning controller with preference persistence."""
|
||||||
|
# Set preference file path
|
||||||
|
config_dir = Path.home() / ".config" / "aisidebar"
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._preference_file = config_dir / "preferences.json"
|
||||||
|
|
||||||
|
# Load preferences from disk
|
||||||
|
self._preferences = self._load_preferences()
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""Check if reasoning mode is active.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if reasoning mode is enabled, False otherwise
|
||||||
|
"""
|
||||||
|
return self._preferences.reasoning_enabled
|
||||||
|
|
||||||
|
def toggle(self) -> bool:
|
||||||
|
"""Toggle reasoning mode and persist preference.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New state of reasoning mode (True if enabled, False if disabled)
|
||||||
|
"""
|
||||||
|
self._preferences.reasoning_enabled = not self._preferences.reasoning_enabled
|
||||||
|
self._save_preferences()
|
||||||
|
return self._preferences.reasoning_enabled
|
||||||
|
|
||||||
|
def get_model_name(self) -> str:
|
||||||
|
"""Return the appropriate model name based on reasoning mode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
THINKING_MODEL if reasoning is enabled, INSTRUCT_MODEL otherwise
|
||||||
|
"""
|
||||||
|
return self.THINKING_MODEL if self._preferences.reasoning_enabled else self.INSTRUCT_MODEL
|
||||||
|
|
||||||
|
def get_model_options(self) -> Dict[str, Any]:
|
||||||
|
"""Return the optimal parameters for the current model.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of model-specific parameters
|
||||||
|
"""
|
||||||
|
if self._preferences.reasoning_enabled:
|
||||||
|
# Thinking model settings
|
||||||
|
return {
|
||||||
|
"temperature": 0.6,
|
||||||
|
"top_p": 0.95,
|
||||||
|
"top_k": 20,
|
||||||
|
"min_p": 0.0,
|
||||||
|
"num_predict": 32768, # Adequate output length
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Instruct model settings
|
||||||
|
return {
|
||||||
|
"temperature": 0.7,
|
||||||
|
"top_p": 0.8,
|
||||||
|
"top_k": 20,
|
||||||
|
"min_p": 0.0,
|
||||||
|
"num_predict": 32768,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _load_preferences(self) -> PreferencesState:
|
||||||
|
"""Load preferences from disk or create defaults.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PreferencesState instance with loaded or default values
|
||||||
|
"""
|
||||||
|
if not self._preference_file.exists():
|
||||||
|
return PreferencesState()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._preference_file.open("r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return PreferencesState(
|
||||||
|
reasoning_enabled=data.get("reasoning_enabled", False),
|
||||||
|
default_model=data.get("default_model"),
|
||||||
|
theme=data.get("theme", "default"),
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
# If file is corrupted or unreadable, return defaults
|
||||||
|
return PreferencesState()
|
||||||
|
|
||||||
|
def _save_preferences(self) -> None:
|
||||||
|
"""Persist preferences to disk atomically."""
|
||||||
|
try:
|
||||||
|
# Convert dataclass to dict
|
||||||
|
data = asdict(self._preferences)
|
||||||
|
|
||||||
|
# Write to temporary file first
|
||||||
|
temp_file = self._preference_file.with_suffix(".tmp")
|
||||||
|
with temp_file.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
|
||||||
|
# Atomic replace
|
||||||
|
os.replace(temp_file, self._preference_file)
|
||||||
|
except OSError:
|
||||||
|
# If save fails, continue without crashing
|
||||||
|
# Preferences will revert to previous state on next load
|
||||||
|
pass
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
PyGObject
|
|
||||||
ollama
|
|
||||||
pytest
|
|
||||||
ruff
|
|
||||||
black
|
|
||||||
19
run.sh
19
run.sh
@@ -1,19 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Launcher script for Niri AI Sidebar with proper Wayland configuration
|
|
||||||
|
|
||||||
# Get the directory where this script is located
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
|
|
||||||
# Activate virtual environment if it exists
|
|
||||||
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
|
||||||
source "$SCRIPT_DIR/.venv/bin/activate"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Force GTK to use native Wayland backend (not XWayland)
|
|
||||||
export GDK_BACKEND=wayland
|
|
||||||
|
|
||||||
# Preload GTK4 Layer Shell library to ensure proper initialization
|
|
||||||
export LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
exec python3 "$SCRIPT_DIR/main.py" "$@"
|
|
||||||
640
sidebar-plan.md
640
sidebar-plan.md
@@ -1,640 +0,0 @@
|
|||||||
<img src="https://r2cdn.perplexity.ai/pplx-full-logo-primary-dark%402x.png" style="height:64px;margin-right:32px"/>
|
|
||||||
|
|
||||||
# AI Chat Sidebar Development Plan - Direct GTK4 Approach
|
|
||||||
|
|
||||||
Based on your comprehensive feasibility assessment and the gathered documentation, here's a production-ready development plan using **direct GTK4 + Python** instead of Ignis.
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
## Documentation Resources
|
|
||||||
|
|
||||||
### Core Documentation
|
|
||||||
|
|
||||||
1. **gtk4-layer-shell**: https://github.com/wmww/gtk4-layer-shell
|
|
||||||
- Python examples in `examples/` directory
|
|
||||||
- API documentation for layer positioning
|
|
||||||
- Installation: `pacman -S gtk4-layer-shell` (Arch) or build from source
|
|
||||||
2. **PyGObject Threading Guide**: https://pygobject.gnome.org/guide/threading.html
|
|
||||||
- Essential patterns for `GLib.idle_add()` usage
|
|
||||||
- Thread-safety guidelines for GTK operations
|
|
||||||
3. **Ollama Python Library**: https://github.com/ollama/ollama-python
|
|
||||||
- Installation: `pip install ollama`
|
|
||||||
- Streaming chat API with `stream=True` parameter
|
|
||||||
4. **Niri Configuration**: https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction
|
|
||||||
- KDL syntax guide
|
|
||||||
- Layer-rules documentation
|
|
||||||
- Live reload capabilities
|
|
||||||
5. **Alpaca Reference Implementation**: https://github.com/Jeffser/Alpaca
|
|
||||||
- Production GTK4 + Ollama patterns
|
|
||||||
- Threading implementation examples
|
|
||||||
- UI/UX patterns for chat interfaces
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
ai-sidebar/
|
|
||||||
├── main.py # Entry point, Gtk.Application setup
|
|
||||||
├── sidebar_window.py # Layer-shell window with GTK4
|
|
||||||
├── ollama_client.py # Ollama API wrapper with threading
|
|
||||||
├── message_widget.py # Individual message bubble
|
|
||||||
├── conversation_manager.py # State management, persistence
|
|
||||||
├── styles.css # GTK CSS styling
|
|
||||||
├── config.py # User settings (model, API endpoint)
|
|
||||||
└── data/
|
|
||||||
└── conversations/ # XDG_DATA_HOME/ai-sidebar/
|
|
||||||
├── index.json # Session index
|
|
||||||
└── {uuid}.json # Individual conversations
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
## Development Phases
|
|
||||||
|
|
||||||
### **Phase 1: Minimal Proof-of-Concept (Days 1-2)**
|
|
||||||
|
|
||||||
**Objective**: Validate gtk4-layer-shell works with Niri and basic Ollama connectivity
|
|
||||||
|
|
||||||
#### Tasks:
|
|
||||||
|
|
||||||
1. **Install dependencies**:
|
|
||||||
```bash
|
|
||||||
# Arch Linux
|
|
||||||
sudo pacman -S gtk4 gtk4-layer-shell python-gobject python-pip
|
|
||||||
pip install ollama
|
|
||||||
|
|
||||||
# Clone examples
|
|
||||||
git clone https://github.com/wmww/gtk4-layer-shell
|
|
||||||
cd gtk4-layer-shell/examples
|
|
||||||
# Study Python examples
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create minimal sidebar** (`minimal_poc.py`):
|
|
||||||
```python
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
import gi
|
|
||||||
gi.require_version('Gtk', '4.0')
|
|
||||||
gi.require_version('Gtk4LayerShell', '1.0')
|
|
||||||
from gi.repository import Gtk, Gtk4LayerShell, GLib
|
|
||||||
import ollama
|
|
||||||
import threading
|
|
||||||
|
|
||||||
class MinimalSidebar(Gtk.ApplicationWindow):
|
|
||||||
def __init__(self, app):
|
|
||||||
super().__init__(application=app, title="AI Sidebar")
|
|
||||||
|
|
||||||
# Initialize layer shell
|
|
||||||
Gtk4LayerShell.init_for_window(self)
|
|
||||||
Gtk4LayerShell.set_namespace(self, "ai-sidebar")
|
|
||||||
Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP)
|
|
||||||
|
|
||||||
# Anchor to left edge, full height
|
|
||||||
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
|
|
||||||
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
|
|
||||||
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
|
|
||||||
Gtk4LayerShell.set_margin(self, Gtk4LayerShell.Edge.LEFT, 0)
|
|
||||||
|
|
||||||
# Request keyboard input
|
|
||||||
Gtk4LayerShell.set_keyboard_mode(self,
|
|
||||||
Gtk4LayerShell.KeyboardMode.ON_DEMAND)
|
|
||||||
|
|
||||||
# Set width
|
|
||||||
self.set_default_size(350, -1)
|
|
||||||
|
|
||||||
# Build UI
|
|
||||||
self.setup_ui()
|
|
||||||
|
|
||||||
def setup_ui(self):
|
|
||||||
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
||||||
|
|
||||||
# Chat display area
|
|
||||||
self.textview = Gtk.TextView()
|
|
||||||
self.textview.set_editable(False)
|
|
||||||
self.textview.set_wrap_mode(Gtk.WrapMode.WORD)
|
|
||||||
self.textview.set_margin_start(10)
|
|
||||||
self.textview.set_margin_end(10)
|
|
||||||
|
|
||||||
scroll = Gtk.ScrolledWindow()
|
|
||||||
scroll.set_child(self.textview)
|
|
||||||
scroll.set_vexpand(True)
|
|
||||||
scroll.set_hexpand(True)
|
|
||||||
|
|
||||||
# Input area
|
|
||||||
input_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
||||||
input_box.set_margin_start(10)
|
|
||||||
input_box.set_margin_end(10)
|
|
||||||
input_box.set_margin_top(5)
|
|
||||||
input_box.set_margin_bottom(10)
|
|
||||||
|
|
||||||
self.entry = Gtk.Entry()
|
|
||||||
self.entry.set_hexpand(True)
|
|
||||||
self.entry.set_placeholder_text("Ask something...")
|
|
||||||
self.entry.connect('activate', self.on_send)
|
|
||||||
|
|
||||||
send_btn = Gtk.Button(label="Send")
|
|
||||||
send_btn.connect('clicked', self.on_send)
|
|
||||||
|
|
||||||
input_box.append(self.entry)
|
|
||||||
input_box.append(send_btn)
|
|
||||||
|
|
||||||
main_box.append(scroll)
|
|
||||||
main_box.append(input_box)
|
|
||||||
|
|
||||||
self.set_child(main_box)
|
|
||||||
|
|
||||||
def append_text(self, text):
|
|
||||||
"""Thread-safe text append"""
|
|
||||||
buffer = self.textview.get_buffer()
|
|
||||||
end_iter = buffer.get_end_iter()
|
|
||||||
buffer.insert(end_iter, text, -1)
|
|
||||||
|
|
||||||
# Auto-scroll to bottom
|
|
||||||
mark = buffer.create_mark(None, end_iter, False)
|
|
||||||
self.textview.scroll_to_mark(mark, 0.0, True, 0.0, 1.0)
|
|
||||||
|
|
||||||
def on_send(self, widget):
|
|
||||||
prompt = self.entry.get_text().strip()
|
|
||||||
if not prompt:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.entry.set_text("")
|
|
||||||
GLib.idle_add(self.append_text, f"\n[You] {prompt}\n")
|
|
||||||
|
|
||||||
def worker():
|
|
||||||
try:
|
|
||||||
GLib.idle_add(self.append_text, "[AI] ")
|
|
||||||
|
|
||||||
# Stream response from Ollama
|
|
||||||
stream = ollama.chat(
|
|
||||||
model='llama3.2', # Use installed model
|
|
||||||
messages=[{'role': 'user', 'content': prompt}],
|
|
||||||
stream=True
|
|
||||||
)
|
|
||||||
|
|
||||||
for chunk in stream:
|
|
||||||
content = chunk['message']['content']
|
|
||||||
GLib.idle_add(self.append_text, content)
|
|
||||||
|
|
||||||
GLib.idle_add(self.append_text, "\n")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
GLib.idle_add(self.append_text, f"\n[Error] {str(e)}\n")
|
|
||||||
|
|
||||||
thread = threading.Thread(target=worker, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
class App(Gtk.Application):
|
|
||||||
def do_activate(self):
|
|
||||||
window = MinimalSidebar(self)
|
|
||||||
window.present()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
app = App()
|
|
||||||
app.run(None)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test basic functionality**:
|
|
||||||
```bash
|
|
||||||
# Ensure Ollama is running with a model
|
|
||||||
ollama pull llama3.2
|
|
||||||
|
|
||||||
# Run the sidebar
|
|
||||||
python minimal_poc.py
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Add Niri configuration** (`~/.config/niri/config.kdl`):
|
|
||||||
```kdl
|
|
||||||
layer-rule {
|
|
||||||
match namespace="^ai-sidebar$"
|
|
||||||
|
|
||||||
// Optional: Add shadow
|
|
||||||
shadow {
|
|
||||||
on
|
|
||||||
softness 40
|
|
||||||
spread 5
|
|
||||||
offset x=0 y=5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binds {
|
|
||||||
// Toggle sidebar with Super+A
|
|
||||||
Mod+A { spawn "python" "/path/to/minimal_poc.py"; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**AI Coding Tool Prompt for Phase 1**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Create a minimal GTK4 application using gtk4-layer-shell that:
|
|
||||||
1. Initializes layer-shell with namespace "ai-sidebar"
|
|
||||||
2. Anchors to left edge, full height, 350px width
|
|
||||||
3. Uses Gtk4LayerShell.Layer.TOP for z-order
|
|
||||||
4. Contains a TextView (read-only) and Entry widget
|
|
||||||
5. Connects Entry's 'activate' signal to send messages
|
|
||||||
6. Uses threading.Thread + GLib.idle_add for Ollama streaming
|
|
||||||
7. Calls ollama.chat() with stream=True in background thread
|
|
||||||
8. Appends each chunk to TextView via GLib.idle_add
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- Python 3.11+, GTK4, gtk4-layer-shell, ollama-python
|
|
||||||
- Thread-safe UI updates only via GLib.idle_add
|
|
||||||
- Basic error handling for connection failures
|
|
||||||
- Auto-scroll TextView to bottom after each message
|
|
||||||
|
|
||||||
Reference PyGObject threading guide patterns.
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
### **Phase 2: Production Features (Days 3-5)**
|
|
||||||
|
|
||||||
**Objective**: Add model selection, conversation persistence, better UI
|
|
||||||
|
|
||||||
#### Components:
|
|
||||||
|
|
||||||
1. **Ollama Client** (`ollama_client.py`):
|
|
||||||
```python
|
|
||||||
import ollama
|
|
||||||
import threading
|
|
||||||
from typing import Callable, Optional, List, Dict
|
|
||||||
from gi.repository import GLib
|
|
||||||
|
|
||||||
class OllamaClient:
|
|
||||||
def __init__(self):
|
|
||||||
self.base_url = "http://localhost:11434"
|
|
||||||
self.current_model = "llama3.2"
|
|
||||||
self._cancel_event = threading.Event()
|
|
||||||
|
|
||||||
def get_models(self) -> List[str]:
|
|
||||||
"""Get list of installed models"""
|
|
||||||
try:
|
|
||||||
models = ollama.list()
|
|
||||||
return [m['name'] for m in models['models']]
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching models: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def stream_chat(
|
|
||||||
self,
|
|
||||||
messages: List[Dict[str, str]],
|
|
||||||
on_chunk: Callable[[str], None],
|
|
||||||
on_complete: Callable[[], None],
|
|
||||||
on_error: Callable[[str], None]
|
|
||||||
):
|
|
||||||
"""Stream chat response in background thread"""
|
|
||||||
self._cancel_event.clear()
|
|
||||||
|
|
||||||
def worker():
|
|
||||||
try:
|
|
||||||
stream = ollama.chat(
|
|
||||||
model=self.current_model,
|
|
||||||
messages=messages,
|
|
||||||
stream=True
|
|
||||||
)
|
|
||||||
|
|
||||||
for chunk in stream:
|
|
||||||
if self._cancel_event.is_set():
|
|
||||||
break
|
|
||||||
|
|
||||||
content = chunk['message']['content']
|
|
||||||
GLib.idle_add(on_chunk, content)
|
|
||||||
|
|
||||||
if not self._cancel_event.is_set():
|
|
||||||
GLib.idle_add(on_complete)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
GLib.idle_add(on_error, str(e))
|
|
||||||
|
|
||||||
thread = threading.Thread(target=worker, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
def cancel(self):
|
|
||||||
"""Cancel current streaming operation"""
|
|
||||||
self._cancel_event.set()
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Conversation Manager** (`conversation_manager.py`):
|
|
||||||
```python
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Dict, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class ConversationManager:
|
|
||||||
def __init__(self):
|
|
||||||
# XDG data directory
|
|
||||||
self.data_dir = Path.home() / ".local/share/ai-sidebar/conversations"
|
|
||||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
self.index_file = self.data_dir / "index.json"
|
|
||||||
self.current_session_id: Optional[str] = None
|
|
||||||
self.messages: List[Dict[str, str]] = []
|
|
||||||
|
|
||||||
def new_session(self) -> str:
|
|
||||||
"""Create new conversation session"""
|
|
||||||
session_id = str(uuid.uuid4())
|
|
||||||
self.current_session_id = session_id
|
|
||||||
self.messages = []
|
|
||||||
self._update_index(session_id)
|
|
||||||
return session_id
|
|
||||||
|
|
||||||
def add_message(self, role: str, content: str):
|
|
||||||
"""Add message to current session"""
|
|
||||||
self.messages.append({
|
|
||||||
"role": role,
|
|
||||||
"content": content,
|
|
||||||
"timestamp": datetime.now().isoformat()
|
|
||||||
})
|
|
||||||
self._save_session()
|
|
||||||
|
|
||||||
def load_session(self, session_id: str) -> List[Dict]:
|
|
||||||
"""Load conversation from file"""
|
|
||||||
session_file = self.data_dir / f"{session_id}.json"
|
|
||||||
if session_file.exists():
|
|
||||||
with open(session_file, 'r') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
self.messages = data.get('messages', [])
|
|
||||||
self.current_session_id = session_id
|
|
||||||
return self.messages
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _save_session(self):
|
|
||||||
"""Save current session to disk"""
|
|
||||||
if not self.current_session_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
session_file = self.data_dir / f"{self.current_session_id}.json"
|
|
||||||
with open(session_file, 'w') as f:
|
|
||||||
json.dump({
|
|
||||||
"session_id": self.current_session_id,
|
|
||||||
"created": datetime.now().isoformat(),
|
|
||||||
"messages": self.messages
|
|
||||||
}, f, indent=2)
|
|
||||||
|
|
||||||
def _update_index(self, session_id: str):
|
|
||||||
"""Update session index"""
|
|
||||||
index = []
|
|
||||||
if self.index_file.exists():
|
|
||||||
with open(self.index_file, 'r') as f:
|
|
||||||
index = json.load(f)
|
|
||||||
|
|
||||||
index.append({
|
|
||||||
"id": session_id,
|
|
||||||
"created": datetime.now().isoformat()
|
|
||||||
})
|
|
||||||
|
|
||||||
with open(self.index_file, 'w') as f:
|
|
||||||
json.dump(index, f, indent=2)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Model Selector Widget**:
|
|
||||||
|
|
||||||
Add to sidebar window:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def setup_header(self):
|
|
||||||
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
|
||||||
header.set_margin_start(10)
|
|
||||||
header.set_margin_end(10)
|
|
||||||
header.set_margin_top(10)
|
|
||||||
|
|
||||||
# Model dropdown
|
|
||||||
self.model_combo = Gtk.ComboBoxText()
|
|
||||||
self.model_combo.set_hexpand(True)
|
|
||||||
self.refresh_models()
|
|
||||||
self.model_combo.connect('changed', self.on_model_changed)
|
|
||||||
|
|
||||||
# New chat button
|
|
||||||
new_btn = Gtk.Button(label="New")
|
|
||||||
new_btn.connect('clicked', self.on_new_chat)
|
|
||||||
|
|
||||||
header.append(self.model_combo)
|
|
||||||
header.append(new_btn)
|
|
||||||
|
|
||||||
return header
|
|
||||||
|
|
||||||
def refresh_models(self):
|
|
||||||
models = self.ollama_client.get_models()
|
|
||||||
self.model_combo.remove_all()
|
|
||||||
for model in models:
|
|
||||||
self.model_combo.append_text(model)
|
|
||||||
if models:
|
|
||||||
self.model_combo.set_active(0)
|
|
||||||
```
|
|
||||||
|
|
||||||
**AI Coding Tool Prompt for Phase 2**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Extend the minimal sidebar with:
|
|
||||||
|
|
||||||
1. OllamaClient class:
|
|
||||||
- get_models() method calling ollama.list()
|
|
||||||
- stream_chat() with threading.Event for cancellation
|
|
||||||
- Callbacks: on_chunk, on_complete, on_error
|
|
||||||
- Thread-safe via GLib.idle_add
|
|
||||||
|
|
||||||
2. ConversationManager class:
|
|
||||||
- XDG data directory: ~/.local/share/ai-sidebar/conversations/
|
|
||||||
- new_session() creates UUID, initializes messages list
|
|
||||||
- add_message(role, content) appends and saves to JSON
|
|
||||||
- load_session(id) loads from {uuid}.json file
|
|
||||||
- Auto-save after each message with fsync
|
|
||||||
|
|
||||||
3. UI additions:
|
|
||||||
- Header box with Gtk.ComboBoxText for model selection
|
|
||||||
- "New Chat" button to clear conversation
|
|
||||||
- Populate ComboBox from get_models()
|
|
||||||
- Update ollama_client.current_model on selection change
|
|
||||||
|
|
||||||
Maintain thread safety and error handling patterns from Phase 1.
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
### **Phase 3: Polish \& Integration (Days 6-7)**
|
|
||||||
|
|
||||||
**Objective**: UI improvements, Niri integration, keyboard shortcuts
|
|
||||||
|
|
||||||
#### Tasks:
|
|
||||||
|
|
||||||
1. **CSS Styling** (`styles.css`):
|
|
||||||
```css
|
|
||||||
window {
|
|
||||||
background-color: #1e1e2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
textview {
|
|
||||||
background-color: #181825;
|
|
||||||
color: #cdd6f4;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry {
|
|
||||||
background-color: #313244;
|
|
||||||
color: #cdd6f4;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background-color: #89b4fa;
|
|
||||||
color: #1e1e2e;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Load in application:
|
|
||||||
|
|
||||||
```python
|
|
||||||
css_provider = Gtk.CssProvider()
|
|
||||||
css_provider.load_from_path('styles.css')
|
|
||||||
Gtk.StyleContext.add_provider_for_display(
|
|
||||||
self.get_display(),
|
|
||||||
css_provider,
|
|
||||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Keyboard Shortcuts**:
|
|
||||||
```python
|
|
||||||
def setup_keybindings(self):
|
|
||||||
event_controller = Gtk.EventControllerKey()
|
|
||||||
event_controller.connect('key-pressed', self.on_key_pressed)
|
|
||||||
self.add_controller(event_controller)
|
|
||||||
|
|
||||||
def on_key_pressed(self, controller, keyval, keycode, state):
|
|
||||||
# Escape to close
|
|
||||||
if keyval == Gdk.KEY_Escape:
|
|
||||||
self.close()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Ctrl+N for new chat
|
|
||||||
if (state & Gdk.ModifierType.CONTROL_MASK and
|
|
||||||
keyval == Gdk.KEY_n):
|
|
||||||
self.on_new_chat(None)
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Niri Toggle Script** (`toggle-sidebar.sh`):
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
PID=$(pgrep -f "python.*main.py")
|
|
||||||
|
|
||||||
if [ -z "$PID" ]; then
|
|
||||||
# Start sidebar
|
|
||||||
python /path/to/ai-sidebar/main.py &
|
|
||||||
else
|
|
||||||
# Kill sidebar
|
|
||||||
kill $PID
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
Update Niri config:
|
|
||||||
|
|
||||||
```kdl
|
|
||||||
binds {
|
|
||||||
Mod+A { spawn "bash" "/path/to/toggle-sidebar.sh"; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Sidebar appears at left edge with correct dimensions
|
|
||||||
- [ ] Layer-shell positioning works (stays on top, doesn't block clicks outside)
|
|
||||||
- [ ] Keyboard input works in Entry widget
|
|
||||||
- [ ] Messages stream smoothly from Ollama
|
|
||||||
- [ ] Model selector populates with installed models
|
|
||||||
- [ ] Model switching changes active model
|
|
||||||
- [ ] New chat clears conversation
|
|
||||||
- [ ] Conversations persist across restarts
|
|
||||||
- [ ] Threading doesn't freeze UI
|
|
||||||
- [ ] Cancel works (if implemented)
|
|
||||||
- [ ] No memory leaks during extended use
|
|
||||||
- [ ] Compatible with Exo shell (no namespace conflicts)
|
|
||||||
- [ ] CSS styling applies correctly
|
|
||||||
- [ ] Escape key closes sidebar
|
|
||||||
- [ ] Toggle script works from Niri keybind
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
## Timeline Summary
|
|
||||||
|
|
||||||
| Phase | Duration | Deliverable |
|
|
||||||
| :-- | :-- | :-- |
|
|
||||||
| Phase 1: POC | 2 days | Working sidebar with basic chat |
|
|
||||||
| Phase 2: Features | 3 days | Model selection, persistence, better UI |
|
|
||||||
| Phase 3: Polish | 2 days | Styling, keybinds, Niri integration |
|
|
||||||
| **Total** | **7 days** | **Production-ready sidebar** |
|
|
||||||
|
|
||||||
**Realistic estimate**: 10-14 days accounting for debugging and learning curve.
|
|
||||||
|
|
||||||
***
|
|
||||||
|
|
||||||
## Key Success Factors
|
|
||||||
|
|
||||||
1. **Start simple**: Phase 1 POC validates everything works before investing time
|
|
||||||
2. **Reference Alpaca**: Study their threading patterns and UI implementations
|
|
||||||
3. **Test incrementally**: Each feature works before moving to next
|
|
||||||
4. **Use AI tools effectively**: Break prompts into discrete components
|
|
||||||
5. **Follow PyGObject patterns**: Threading via `GLib.idle_add()` is critical
|
|
||||||
|
|
||||||
This plan avoids Ignis instability while achieving your goal with mature, well-documented technologies.[^1][^2][^3]
|
|
||||||
<span style="display:none">[^10][^11][^12][^13][^14][^15][^16][^17][^18][^19][^20][^21][^22][^4][^5][^6][^7][^8][^9]</span>
|
|
||||||
|
|
||||||
<div align="center">⁂</div>
|
|
||||||
|
|
||||||
[^1]: https://pygobject.gnome.org/guide/threading.html
|
|
||||||
|
|
||||||
[^2]: https://github.com/wmww/gtk4-layer-shell
|
|
||||||
|
|
||||||
[^3]: https://github.com/Jeffser/Alpaca
|
|
||||||
|
|
||||||
[^4]: https://stackoverflow.com/questions/73665239/implementing-threading-in-a-python-gtk-application-pygobject-to-prevent-ui-fre
|
|
||||||
|
|
||||||
[^5]: https://gitlab.gnome.org/GNOME/pygobject/-/blob/3.49.0/docs/guide/threading.rst
|
|
||||||
|
|
||||||
[^6]: https://discourse.gnome.org/t/gtk-threading-problem-with-glib-idle-add/13597
|
|
||||||
|
|
||||||
[^7]: https://gist.github.com/bossjones/e21b53c6dff04e8fdb3d
|
|
||||||
|
|
||||||
[^8]: https://dunkelstern.de/articles/2025-01-24/index.html
|
|
||||||
|
|
||||||
[^9]: https://www.glukhov.org/post/2025/10/ollama-python-examples/
|
|
||||||
|
|
||||||
[^10]: https://www.reddit.com/r/learnpython/comments/fa9612/pygtk_glade_threading/
|
|
||||||
|
|
||||||
[^11]: https://github.com/YaLTeR/niri/wiki/Configuration:-Introduction
|
|
||||||
|
|
||||||
[^12]: https://github.com/ollama/ollama-python
|
|
||||||
|
|
||||||
[^13]: https://pygtk.daa.com.narkive.com/QpH3Y5ky/idle-add-vs-threads-enter-threads-leave
|
|
||||||
|
|
||||||
[^14]: https://github.com/YaLTeR/niri/discussions/674
|
|
||||||
|
|
||||||
[^15]: https://ollama.com/blog/streaming-tool
|
|
||||||
|
|
||||||
[^16]: https://docs.gtk.org/glib/func.idle_add.html
|
|
||||||
|
|
||||||
[^17]: https://yalter.github.io/niri/Configuration:-Window-Rules.html
|
|
||||||
|
|
||||||
[^18]: https://www.cohorte.co/blog/using-ollama-with-python-step-by-step-guide
|
|
||||||
|
|
||||||
[^19]: https://gnulinux.ch/ein-kleines-gtk4-programm-in-python
|
|
||||||
|
|
||||||
[^20]: https://yalter.github.io/niri/Getting-Started.html
|
|
||||||
|
|
||||||
[^21]: https://www.reddit.com/r/Python/comments/1ael05l/ollama_python_library_chat_method_system_message/
|
|
||||||
|
|
||||||
[^22]: https://git.yaroslavps.com/configs/swayrice/tree/dotfiles/.config/niri/config.kdl?id=dd00aee82134d4f1b41463c5371f1ee943a9ec7a
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
"""GTK sidebar window definitions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
import gi
|
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
|
||||||
from gi.repository import GLib, Gtk # noqa: E402
|
|
||||||
|
|
||||||
try: # pragma: no cover - optional dependency may not be available in CI
|
|
||||||
gi.require_version("Gtk4LayerShell", "1.0")
|
|
||||||
from gi.repository import Gtk4LayerShell # type: ignore[attr-defined]
|
|
||||||
except (ImportError, ValueError): # pragma: no cover - fallback path
|
|
||||||
Gtk4LayerShell = None # type: ignore[misc]
|
|
||||||
|
|
||||||
from conversation_manager import ConversationManager
|
|
||||||
from ollama_client import OllamaClient
|
|
||||||
|
|
||||||
|
|
||||||
class SidebarWindow(Gtk.ApplicationWindow):
|
|
||||||
"""Layer-shell anchored window hosting the chat interface."""
|
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
|
|
||||||
# CRITICAL: Layer shell must be initialized BEFORE any window properties
|
|
||||||
self._setup_layer_shell()
|
|
||||||
|
|
||||||
self.set_default_size(360, 720)
|
|
||||||
self.set_title("Niri AI Sidebar")
|
|
||||||
self.set_hide_on_close(False)
|
|
||||||
|
|
||||||
self._conversation_manager = ConversationManager()
|
|
||||||
self._ollama_client = OllamaClient()
|
|
||||||
self._current_model = self._ollama_client.default_model
|
|
||||||
|
|
||||||
self._build_ui()
|
|
||||||
self._populate_initial_messages()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ UI setup
|
|
||||||
def _setup_layer_shell(self) -> None:
|
|
||||||
"""Attach the window to the left edge via gtk4-layer-shell when available."""
|
|
||||||
if Gtk4LayerShell is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
Gtk4LayerShell.init_for_window(self)
|
|
||||||
|
|
||||||
# Verify initialization succeeded before configuring layer shell properties
|
|
||||||
if not Gtk4LayerShell.is_layer_window(self):
|
|
||||||
return
|
|
||||||
|
|
||||||
Gtk4LayerShell.set_namespace(self, "niri-ai-sidebar")
|
|
||||||
Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP)
|
|
||||||
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
|
|
||||||
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
|
|
||||||
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
|
|
||||||
Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, False)
|
|
||||||
Gtk4LayerShell.set_margin(self, Gtk4LayerShell.Edge.LEFT, 0)
|
|
||||||
Gtk4LayerShell.set_keyboard_mode(
|
|
||||||
self, Gtk4LayerShell.KeyboardMode.ON_DEMAND
|
|
||||||
)
|
|
||||||
Gtk4LayerShell.set_exclusive_zone(self, -1)
|
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
|
||||||
"""Create the core layout: message history and input entry."""
|
|
||||||
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
|
||||||
main_box.set_margin_top(16)
|
|
||||||
main_box.set_margin_bottom(16)
|
|
||||||
main_box.set_margin_start(16)
|
|
||||||
main_box.set_margin_end(16)
|
|
||||||
main_box.set_hexpand(True)
|
|
||||||
main_box.set_vexpand(True)
|
|
||||||
|
|
||||||
header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
|
||||||
header_title = Gtk.Label(label="Niri AI Sidebar")
|
|
||||||
header_title.set_halign(Gtk.Align.START)
|
|
||||||
header_title.get_style_context().add_class("title-2")
|
|
||||||
|
|
||||||
model_name = self._current_model or "No local model detected"
|
|
||||||
self._model_label = Gtk.Label(label=f"Model: {model_name}")
|
|
||||||
self._model_label.set_halign(Gtk.Align.START)
|
|
||||||
self._model_label.get_style_context().add_class("dim-label")
|
|
||||||
|
|
||||||
header_box.append(header_title)
|
|
||||||
header_box.append(self._model_label)
|
|
||||||
|
|
||||||
self._message_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
||||||
self._message_list.set_hexpand(True)
|
|
||||||
self._message_list.set_vexpand(True)
|
|
||||||
self._message_list.set_valign(Gtk.Align.START)
|
|
||||||
|
|
||||||
scroller = Gtk.ScrolledWindow()
|
|
||||||
scroller.set_hexpand(True)
|
|
||||||
scroller.set_vexpand(True)
|
|
||||||
scroller.set_child(self._message_list)
|
|
||||||
scroller.set_min_content_height(300)
|
|
||||||
self._scroller = scroller
|
|
||||||
|
|
||||||
input_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
||||||
input_box.set_hexpand(True)
|
|
||||||
|
|
||||||
self._entry = Gtk.Entry()
|
|
||||||
self._entry.set_hexpand(True)
|
|
||||||
self._entry.set_placeholder_text("Ask a question…")
|
|
||||||
self._entry.connect("activate", self._on_submit)
|
|
||||||
|
|
||||||
# Add focus event controller to properly handle focus-out events
|
|
||||||
# The handler must return False to propagate the event to GTK's default handler
|
|
||||||
focus_controller = Gtk.EventControllerFocus()
|
|
||||||
focus_controller.connect("leave", lambda c: False)
|
|
||||||
self._entry.add_controller(focus_controller)
|
|
||||||
|
|
||||||
self._send_button = Gtk.Button(label="Send")
|
|
||||||
self._send_button.connect("clicked", self._on_submit)
|
|
||||||
|
|
||||||
input_box.append(self._entry)
|
|
||||||
input_box.append(self._send_button)
|
|
||||||
|
|
||||||
main_box.append(header_box)
|
|
||||||
main_box.append(scroller)
|
|
||||||
main_box.append(input_box)
|
|
||||||
|
|
||||||
self.set_child(main_box)
|
|
||||||
|
|
||||||
def _populate_initial_messages(self) -> None:
|
|
||||||
"""Render conversation history stored on disk."""
|
|
||||||
for message in self._conversation_manager.messages:
|
|
||||||
self._append_message(message["role"], message["content"], persist=False)
|
|
||||||
|
|
||||||
if not self._conversation_manager.messages:
|
|
||||||
self._append_message(
|
|
||||||
"assistant",
|
|
||||||
"Welcome! Ask a question to start a conversation.",
|
|
||||||
persist=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ helpers
|
|
||||||
def _append_message(
|
|
||||||
self, role: str, content: str, *, persist: bool = True
|
|
||||||
) -> None:
|
|
||||||
"""Add a message bubble to the history and optionally persist it."""
|
|
||||||
label_prefix = "You" if role == "user" else "Assistant"
|
|
||||||
label = Gtk.Label(label=f"{label_prefix}: {content}")
|
|
||||||
label.set_halign(Gtk.Align.START)
|
|
||||||
label.set_xalign(0.0)
|
|
||||||
label.set_wrap(True)
|
|
||||||
label.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
|
||||||
label.set_justify(Gtk.Justification.LEFT)
|
|
||||||
|
|
||||||
self._message_list.append(label)
|
|
||||||
self._scroll_to_bottom()
|
|
||||||
|
|
||||||
if persist:
|
|
||||||
self._conversation_manager.append_message(role, content)
|
|
||||||
|
|
||||||
def _scroll_to_bottom(self) -> None:
|
|
||||||
"""Ensure the most recent message is visible."""
|
|
||||||
def _scroll() -> bool:
|
|
||||||
adjustment = self._scroller.get_vadjustment()
|
|
||||||
if adjustment is not None:
|
|
||||||
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size())
|
|
||||||
return False
|
|
||||||
|
|
||||||
GLib.idle_add(_scroll)
|
|
||||||
|
|
||||||
def _set_input_enabled(self, enabled: bool) -> None:
|
|
||||||
self._entry.set_sensitive(enabled)
|
|
||||||
self._send_button.set_sensitive(enabled)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ callbacks
|
|
||||||
def _on_submit(self, _widget: Gtk.Widget) -> None:
|
|
||||||
"""Handle send button clicks or entry activation."""
|
|
||||||
text = self._entry.get_text().strip()
|
|
||||||
if not text:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._entry.set_text("")
|
|
||||||
self._append_message("user", text, persist=True)
|
|
||||||
self._request_response()
|
|
||||||
|
|
||||||
def _request_response(self) -> None:
|
|
||||||
"""Trigger a synchronous Ollama chat call on a worker thread."""
|
|
||||||
model = self._current_model or self._ollama_client.default_model
|
|
||||||
if not model:
|
|
||||||
self._append_message(
|
|
||||||
"assistant",
|
|
||||||
"No Ollama models are available. Install a model to continue.",
|
|
||||||
persist=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
history = self._conversation_manager.chat_messages
|
|
||||||
self._set_input_enabled(False)
|
|
||||||
|
|
||||||
def _worker(messages: Iterable[dict[str, str]]) -> None:
|
|
||||||
response = self._ollama_client.chat(model=model, messages=list(messages))
|
|
||||||
GLib.idle_add(self._handle_response, response, priority=GLib.PRIORITY_DEFAULT)
|
|
||||||
|
|
||||||
thread = threading.Thread(target=_worker, args=(history,), daemon=True)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
def _handle_response(self, response: dict[str, str] | None) -> bool:
|
|
||||||
"""Render the assistant reply and re-enable the entry."""
|
|
||||||
self._set_input_enabled(True)
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
self._append_message(
|
|
||||||
"assistant",
|
|
||||||
"The model returned an empty response.",
|
|
||||||
persist=True,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
role = response.get("role", "assistant")
|
|
||||||
content = response.get("content") or ""
|
|
||||||
if not content:
|
|
||||||
content = "[No content received from Ollama]"
|
|
||||||
|
|
||||||
self._append_message(role, content, persist=True)
|
|
||||||
return False
|
|
||||||
222
streaming_handler.py
Normal file
222
streaming_handler.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""Streaming response handler for progressive token display."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from gi.repository import GLib
|
||||||
|
from ignis import widgets
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingHandler:
|
||||||
|
"""Manages streaming response display with token-by-token updates."""
|
||||||
|
|
||||||
|
def __init__(self, message_widget: widgets.Label, scroller: widgets.Scroll,
|
||||||
|
thinking_widget: widgets.Label = None, thinking_box: widgets.Box = None):
|
||||||
|
"""Initialize streaming handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_widget: The label widget to update with streaming content
|
||||||
|
scroller: The scroll widget to manage auto-scrolling
|
||||||
|
thinking_widget: Optional label widget for thinking content
|
||||||
|
thinking_box: Optional box container for thinking section
|
||||||
|
"""
|
||||||
|
self._widget = message_widget
|
||||||
|
self._thinking_widget = thinking_widget
|
||||||
|
self._thinking_box = thinking_box
|
||||||
|
self._scroller = scroller
|
||||||
|
self._buffer = ""
|
||||||
|
self._thinking_buffer = "" # Separate buffer for thinking content
|
||||||
|
self._token_buffer = []
|
||||||
|
self._thinking_token_buffer = []
|
||||||
|
self._is_streaming = False
|
||||||
|
self._buffer_size = 3 # Accumulate 3-5 tokens before UI update
|
||||||
|
|
||||||
|
def start_stream(self) -> None:
|
||||||
|
"""Initialize streaming state."""
|
||||||
|
self._buffer = ""
|
||||||
|
self._thinking_buffer = ""
|
||||||
|
self._token_buffer = []
|
||||||
|
self._thinking_token_buffer = []
|
||||||
|
self._is_streaming = True
|
||||||
|
# Set initial text with streaming indicator
|
||||||
|
self._widget.label = "Assistant: ..."
|
||||||
|
|
||||||
|
def append_token(self, token: str) -> None:
|
||||||
|
"""Add token to buffer and update UI via GLib.idle_add.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The token string to append
|
||||||
|
"""
|
||||||
|
if not self._is_streaming:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add token to buffer
|
||||||
|
self._token_buffer.append(token)
|
||||||
|
|
||||||
|
# Update UI when buffer reaches threshold
|
||||||
|
if len(self._token_buffer) >= self._buffer_size:
|
||||||
|
self._flush_buffer()
|
||||||
|
|
||||||
|
def append_thinking_token(self, token: str) -> None:
|
||||||
|
"""Add thinking token to separate buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The thinking token string to append
|
||||||
|
"""
|
||||||
|
if not self._is_streaming:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add token to thinking buffer
|
||||||
|
self._thinking_token_buffer.append(token)
|
||||||
|
|
||||||
|
# Update UI when buffer reaches threshold
|
||||||
|
if len(self._thinking_token_buffer) >= self._buffer_size:
|
||||||
|
self._flush_thinking_buffer()
|
||||||
|
|
||||||
|
def _flush_buffer(self) -> None:
|
||||||
|
"""Flush accumulated tokens to UI."""
|
||||||
|
if not self._token_buffer:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Combine buffered tokens
|
||||||
|
tokens = "".join(self._token_buffer)
|
||||||
|
self._buffer += tokens
|
||||||
|
self._token_buffer = []
|
||||||
|
|
||||||
|
# Schedule UI update on main thread
|
||||||
|
GLib.idle_add(self._update_ui, priority=GLib.PRIORITY_DEFAULT)
|
||||||
|
|
||||||
|
def _flush_thinking_buffer(self) -> None:
|
||||||
|
"""Flush accumulated thinking tokens to UI."""
|
||||||
|
if not self._thinking_token_buffer:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Combine buffered thinking tokens
|
||||||
|
tokens = "".join(self._thinking_token_buffer)
|
||||||
|
self._thinking_buffer += tokens
|
||||||
|
self._thinking_token_buffer = []
|
||||||
|
|
||||||
|
# Schedule UI update on main thread
|
||||||
|
GLib.idle_add(self._update_ui, priority=GLib.PRIORITY_DEFAULT)
|
||||||
|
|
||||||
|
def _update_ui(self) -> bool:
|
||||||
|
"""Update the widget label with current buffer content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
False to indicate this is a one-time callback
|
||||||
|
"""
|
||||||
|
# Update thinking widget if present
|
||||||
|
if self._thinking_widget and self._thinking_buffer:
|
||||||
|
self._thinking_widget.label = self._thinking_buffer + "..."
|
||||||
|
if self._thinking_box:
|
||||||
|
self._thinking_box.set_visible(True)
|
||||||
|
|
||||||
|
# Update main response label with streaming indicator
|
||||||
|
self._widget.label = f"Assistant: {self._buffer}..."
|
||||||
|
|
||||||
|
# Auto-scroll to bottom
|
||||||
|
self._scroll_to_bottom()
|
||||||
|
|
||||||
|
return False # Don't repeat this callback
|
||||||
|
|
||||||
|
def _scroll_to_bottom(self) -> None:
|
||||||
|
"""Scroll to the latest content."""
|
||||||
|
adjustment = self._scroller.get_vadjustment()
|
||||||
|
if adjustment:
|
||||||
|
adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size())
|
||||||
|
|
||||||
|
def finish_stream(self) -> tuple[str, str]:
|
||||||
|
"""Finalize streaming and return complete content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (thinking_content, main_content)
|
||||||
|
"""
|
||||||
|
self._is_streaming = False
|
||||||
|
|
||||||
|
# Flush any remaining tokens
|
||||||
|
if self._token_buffer:
|
||||||
|
self._buffer += "".join(self._token_buffer)
|
||||||
|
self._token_buffer = []
|
||||||
|
|
||||||
|
if self._thinking_token_buffer:
|
||||||
|
self._thinking_buffer += "".join(self._thinking_token_buffer)
|
||||||
|
self._thinking_token_buffer = []
|
||||||
|
|
||||||
|
# Final UI update without streaming indicator
|
||||||
|
final_content = self._buffer
|
||||||
|
final_thinking = self._thinking_buffer
|
||||||
|
GLib.idle_add(self._finalize_ui, final_thinking, final_content, priority=GLib.PRIORITY_DEFAULT)
|
||||||
|
|
||||||
|
return (final_thinking, final_content)
|
||||||
|
|
||||||
|
def _finalize_ui(self, thinking: str, content: str) -> bool:
|
||||||
|
"""Update UI with final content without streaming indicator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
thinking: The final thinking content
|
||||||
|
content: The final complete content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
False to indicate this is a one-time callback
|
||||||
|
"""
|
||||||
|
# Update thinking widget if present
|
||||||
|
if self._thinking_widget and thinking:
|
||||||
|
self._thinking_widget.label = thinking
|
||||||
|
if self._thinking_box:
|
||||||
|
self._thinking_box.set_visible(True)
|
||||||
|
|
||||||
|
# Update main response without streaming indicator
|
||||||
|
self._widget.label = f"Assistant: {content}"
|
||||||
|
self._scroll_to_bottom()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def parse_reasoning_content(self, content: str) -> tuple[str | None, str]:
|
||||||
|
"""Parse reasoning content from response if present.
|
||||||
|
|
||||||
|
Looks for common reasoning patterns like:
|
||||||
|
- <think>...</think> or <thinking>...</thinking> tags
|
||||||
|
- [Reasoning: ...] markers
|
||||||
|
- Other model-specific formats
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The complete response content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (reasoning_content, main_content)
|
||||||
|
If no reasoning found, returns (None, original_content)
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Pattern 1a: <think>...</think> tags (shorter form)
|
||||||
|
think_pattern = r'<think>(.*?)</think>\s*(.*)'
|
||||||
|
match = re.search(think_pattern, content, re.DOTALL | re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
reasoning = match.group(1).strip()
|
||||||
|
main_content = match.group(2).strip()
|
||||||
|
return (reasoning, main_content)
|
||||||
|
|
||||||
|
# Pattern 1b: <thinking>...</thinking> tags (longer form)
|
||||||
|
thinking_pattern = r'<thinking>(.*?)</thinking>\s*(.*)'
|
||||||
|
match = re.search(thinking_pattern, content, re.DOTALL | re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
reasoning = match.group(1).strip()
|
||||||
|
main_content = match.group(2).strip()
|
||||||
|
return (reasoning, main_content)
|
||||||
|
|
||||||
|
# Pattern 2: [Reasoning: ...] followed by [Answer: ...]
|
||||||
|
reasoning_pattern = r'\[Reasoning:?\s*(.*?)\]\s*\[Answer:?\s*(.*?)\]'
|
||||||
|
match = re.search(reasoning_pattern, content, re.DOTALL | re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
reasoning = match.group(1).strip()
|
||||||
|
main_content = match.group(2).strip()
|
||||||
|
return (reasoning, main_content)
|
||||||
|
|
||||||
|
# Pattern 3: "Reasoning:" followed by "Answer:" or "Conclusion:"
|
||||||
|
reasoning_pattern2 = r'Reasoning:\s*(.*?)\s*(?:Answer|Conclusion):\s*(.*)'
|
||||||
|
match = re.search(reasoning_pattern2, content, re.DOTALL | re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
reasoning = match.group(1).strip()
|
||||||
|
main_content = match.group(2).strip()
|
||||||
|
return (reasoning, main_content)
|
||||||
|
|
||||||
|
# No reasoning pattern found
|
||||||
|
return (None, content)
|
||||||
74
style.css
Normal file
74
style.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/* AI Sidebar TextView styling */
|
||||||
|
textview {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textview text {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-view-container {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrolledwindow {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reasoning/Thinking content styling */
|
||||||
|
.thinking-box {
|
||||||
|
background: linear-gradient(135deg, rgba(100, 150, 255, 0.15) 0%, rgba(80, 120, 200, 0.1) 100%);
|
||||||
|
border: 1px solid rgba(100, 150, 255, 0.3);
|
||||||
|
border-left: 4px solid rgba(100, 150, 255, 0.8);
|
||||||
|
padding: 12px;
|
||||||
|
margin: 8px 0px 12px 0px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-header-button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-header-button:hover {
|
||||||
|
background: rgba(100, 150, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-icon {
|
||||||
|
color: rgba(120, 170, 255, 1.0);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-header {
|
||||||
|
color: rgba(120, 170, 255, 1.0);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-content {
|
||||||
|
color: rgba(200, 210, 230, 0.9);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-content {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-divider {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
/* Global styles placeholder for the AI sidebar. */
|
|
||||||
|
|
||||||
body {
|
|
||||||
/* Replace with GTK CSS once widgets are implemented. */
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from conversation_manager import ConversationManager
|
|
||||||
|
|
||||||
|
|
||||||
def test_conversation_manager_persists_history(tmp_path: Path) -> None:
|
|
||||||
manager = ConversationManager(storage_dir=tmp_path, conversation_id="test")
|
|
||||||
manager.append_message("user", "Hello there!")
|
|
||||||
manager.append_message("assistant", "General Kenobi.")
|
|
||||||
|
|
||||||
conversation_file = tmp_path / "test.json"
|
|
||||||
assert conversation_file.exists()
|
|
||||||
|
|
||||||
data = json.loads(conversation_file.read_text(encoding="utf-8"))
|
|
||||||
assert len(data["messages"]) == 2
|
|
||||||
assert data["messages"][0]["content"] == "Hello there!"
|
|
||||||
|
|
||||||
reloaded = ConversationManager(storage_dir=tmp_path, conversation_id="test")
|
|
||||||
assert [msg["content"] for msg in reloaded.messages] == [
|
|
||||||
"Hello there!",
|
|
||||||
"General Kenobi.",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_conversation_manager_rejects_invalid_role(tmp_path: Path) -> None:
|
|
||||||
manager = ConversationManager(storage_dir=tmp_path, conversation_id="invalid")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
manager.append_message("narrator", "This should fail.")
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_runs_headless() -> None:
|
|
||||||
repo_root = Path(__file__).resolve().parents[1]
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["AI_SIDEBAR_HEADLESS"] = "1"
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "main.py"],
|
|
||||||
cwd=repo_root,
|
|
||||||
env=env,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.returncode == 0
|
|
||||||
assert "Headless mode enabled" in result.stdout
|
|
||||||
Reference in New Issue
Block a user