Compare commits
34 Commits
v0.1.2
...
235f84fa19
| Author | SHA1 | Date | |
|---|---|---|---|
| 235f84fa19 | |||
| 9c777c8429 | |||
| 0b17a0f4c8 | |||
| 2eabe55fe6 | |||
| 4d7ad2c330 | |||
| 13af046eff | |||
| 5b202fed4f | |||
| 979347bf53 | |||
| 76b55ccff5 | |||
| f0e162d551 | |||
| 6c4571804f | |||
| a0cdcfdf6c | |||
| 96e2482782 | |||
| 6a3f44f911 | |||
| e0e5a2a83d | |||
| 23e86591d1 | |||
| b60a317788 | |||
| 2788e8b7e2 | |||
| 7c186882dc | |||
| bdda669d4d | |||
| 108070db4b | |||
| 08ba04e99f | |||
| e58032deae | |||
| 5c59539120 | |||
| c725bb1ce6 | |||
| c4a6bb1c0f | |||
| dcbfe6ef06 | |||
| e468658d63 | |||
| 2ad801f0c1 | |||
| 1bfc6e5956 | |||
| 6b8774f0aa | |||
| ec6876727f | |||
| e3eb4d7a04 | |||
| 7234021014 |
20
.cargo/config.toml
Normal file
20
.cargo/config.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "x86_64-linux-gnu-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
linker = "arm-linux-gnueabihf-gcc"
|
||||
|
||||
[target.armv7-unknown-linux-musleabihf]
|
||||
linker = "arm-linux-gnueabihf-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,6 +4,10 @@
|
||||
debug/
|
||||
target/
|
||||
dev/
|
||||
.agents/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
@@ -100,4 +104,3 @@ fabric.properties
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
|
||||
34
.pre-commit-config.yaml
Normal file
34
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Pre-commit hooks configuration
|
||||
# See https://pre-commit.com for more information
|
||||
|
||||
repos:
|
||||
# General file checks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-toml
|
||||
- id: check-merge-conflict
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=1000']
|
||||
- id: mixed-line-ending
|
||||
|
||||
# Rust formatting
|
||||
- repo: https://github.com/doublify/pre-commit-rust
|
||||
rev: v1.0
|
||||
hooks:
|
||||
- id: fmt
|
||||
name: cargo fmt
|
||||
description: Format Rust code with rustfmt
|
||||
- id: cargo-check
|
||||
name: cargo check
|
||||
description: Check Rust code compilation
|
||||
- id: clippy
|
||||
name: cargo clippy
|
||||
description: Lint Rust code with clippy
|
||||
args: ['--all-features', '--', '-D', 'warnings']
|
||||
|
||||
# Optional: run on all files when config changes
|
||||
default_install_hook_types: [pre-commit, pre-push]
|
||||
@@ -11,63 +11,110 @@ matrix:
|
||||
- TARGET: x86_64-unknown-linux-gnu
|
||||
ARTIFACT: owlen-linux-x86_64-gnu
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
- TARGET: x86_64-unknown-linux-musl
|
||||
ARTIFACT: owlen-linux-x86_64-musl
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
- TARGET: aarch64-unknown-linux-gnu
|
||||
ARTIFACT: owlen-linux-aarch64-gnu
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
- TARGET: aarch64-unknown-linux-musl
|
||||
ARTIFACT: owlen-linux-aarch64-musl
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
- TARGET: armv7-unknown-linux-gnueabihf
|
||||
ARTIFACT: owlen-linux-armv7-gnu
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
- TARGET: armv7-unknown-linux-musleabihf
|
||||
ARTIFACT: owlen-linux-armv7-musl
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
# Windows
|
||||
- TARGET: x86_64-pc-windows-gnu
|
||||
ARTIFACT: owlen-windows-x86_64
|
||||
PLATFORM: windows
|
||||
EXT: ".exe"
|
||||
|
||||
steps:
|
||||
- name: install-deps
|
||||
image: *rust_image
|
||||
commands:
|
||||
- apt-get update
|
||||
- apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
|
||||
|
||||
- name: build
|
||||
image: *rust_image
|
||||
commands:
|
||||
# Install cross-compilation tools
|
||||
- apt-get update
|
||||
- apt-get install -y musl-tools gcc-aarch64-linux-gnu g++-aarch64-linux-gnu gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf mingw-w64 zip
|
||||
|
||||
# Verify cross-compilers are installed
|
||||
- which aarch64-linux-gnu-gcc || echo "aarch64-linux-gnu-gcc not found!"
|
||||
- which arm-linux-gnueabihf-gcc || echo "arm-linux-gnueabihf-gcc not found!"
|
||||
- which x86_64-w64-mingw32-gcc || echo "x86_64-w64-mingw32-gcc not found!"
|
||||
|
||||
# Add rust target
|
||||
- rustup target add ${TARGET}
|
||||
|
||||
# Set up cross-compilation environment variables and build
|
||||
- |
|
||||
case "${TARGET}" in
|
||||
aarch64-unknown-linux-gnu)
|
||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/aarch64-linux-gnu-gcc
|
||||
export CC_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-gcc
|
||||
export CXX_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-g++
|
||||
export AR_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-ar
|
||||
;;
|
||||
aarch64-unknown-linux-musl)
|
||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
|
||||
export CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc
|
||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=/usr/bin/aarch64-linux-gnu-gcc
|
||||
export CC_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-gcc
|
||||
export CXX_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-g++
|
||||
export AR_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-ar
|
||||
;;
|
||||
armv7-unknown-linux-gnueabihf)
|
||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc
|
||||
export CC_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-gcc
|
||||
export CXX_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-g++
|
||||
export AR_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-ar
|
||||
;;
|
||||
armv7-unknown-linux-musleabihf)
|
||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
||||
export CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc
|
||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc
|
||||
export CC_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-gcc
|
||||
export CXX_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-g++
|
||||
export AR_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-ar
|
||||
;;
|
||||
x86_64-pc-windows-gnu)
|
||||
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=/usr/bin/x86_64-w64-mingw32-gcc
|
||||
export CC_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-gcc
|
||||
export CXX_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-g++
|
||||
export AR_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-ar
|
||||
;;
|
||||
esac
|
||||
- cargo build --release --all-features --target ${TARGET}
|
||||
|
||||
# Build the project
|
||||
cargo build --release --all-features --target ${TARGET}
|
||||
|
||||
- name: package
|
||||
image: *rust_image
|
||||
commands:
|
||||
- apt-get update && apt-get install -y zip
|
||||
- mkdir -p dist
|
||||
- cp target/${TARGET}/release/owlen dist/owlen
|
||||
- cp target/${TARGET}/release/owlen-code dist/owlen-code
|
||||
- cd dist
|
||||
- tar czf ${ARTIFACT}.tar.gz owlen owlen-code
|
||||
- cd ..
|
||||
- mv dist/${ARTIFACT}.tar.gz .
|
||||
- sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
|
||||
- |
|
||||
if [ "${PLATFORM}" = "windows" ]; then
|
||||
cp target/${TARGET}/release/owlen.exe dist/owlen.exe
|
||||
cp target/${TARGET}/release/owlen-code.exe dist/owlen-code.exe
|
||||
cd dist
|
||||
zip -9 ${ARTIFACT}.zip owlen.exe owlen-code.exe
|
||||
cd ..
|
||||
mv dist/${ARTIFACT}.zip .
|
||||
sha256sum ${ARTIFACT}.zip > ${ARTIFACT}.zip.sha256
|
||||
else
|
||||
cp target/${TARGET}/release/owlen dist/owlen
|
||||
cp target/${TARGET}/release/owlen-code dist/owlen-code
|
||||
cd dist
|
||||
tar czf ${ARTIFACT}.tar.gz owlen owlen-code
|
||||
cd ..
|
||||
mv dist/${ARTIFACT}.tar.gz .
|
||||
sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
|
||||
fi
|
||||
|
||||
- name: release
|
||||
image: plugins/gitea-release
|
||||
@@ -78,5 +125,7 @@ steps:
|
||||
files:
|
||||
- ${ARTIFACT}.tar.gz
|
||||
- ${ARTIFACT}.tar.gz.sha256
|
||||
- ${ARTIFACT}.zip
|
||||
- ${ARTIFACT}.zip.sha256
|
||||
title: Release ${CI_COMMIT_TAG}
|
||||
note: "Release ${CI_COMMIT_TAG}"
|
||||
|
||||
84
CHANGELOG.md
Normal file
84
CHANGELOG.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Comprehensive documentation suite including guides for architecture, configuration, testing, and more.
|
||||
- Rustdoc examples for core components like `Provider` and `SessionController`.
|
||||
- Module-level documentation for `owlen-tui`.
|
||||
- Ollama integration can now talk to Ollama Cloud when an API key is configured.
|
||||
- Ollama provider will also read `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` environment variables when no key is stored in the config.
|
||||
|
||||
### Changed
|
||||
- The main `README.md` has been updated to be more concise and link to the new documentation.
|
||||
- Default configuration now pre-populates both `providers.ollama` and `providers.ollama-cloud` entries so switching between local and cloud backends is a single setting change.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.10] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- **Material Light Theme**: A new built-in theme, `material-light`, has been added.
|
||||
|
||||
### Fixed
|
||||
- **UI Readability**: Fixed a bug causing unreadable text in light themes.
|
||||
- **Visual Selection**: The visual selection mode now correctly colors unselected text portions.
|
||||
|
||||
### Changed
|
||||
- **Theme Colors**: The color palettes for `gruvbox`, `rose-pine`, and `monokai` have been corrected.
|
||||
- **In-App Help**: The `:help` menu has been significantly expanded and updated.
|
||||
|
||||
## [0.1.9] - 2025-10-03
|
||||
|
||||
*This version corresponds to the release tagged v0.1.10 in the source repository.*
|
||||
|
||||
### Added
|
||||
- **Material Light Theme**: A new built-in theme, `material-light`, has been added.
|
||||
|
||||
### Fixed
|
||||
- **UI Readability**: Fixed a bug causing unreadable text in light themes.
|
||||
- **Visual Selection**: The visual selection mode now correctly colors unselected text portions.
|
||||
|
||||
### Changed
|
||||
- **Theme Colors**: The color palettes for `gruvbox`, `rose-pine`, and `monokai` have been corrected.
|
||||
- **In-App Help**: The `:help` menu has been significantly expanded and updated.
|
||||
|
||||
## [0.1.8] - 2025-10-02
|
||||
|
||||
### Added
|
||||
- **Command Autocompletion**: Implemented intelligent command suggestions and Tab completion in command mode.
|
||||
|
||||
### Changed
|
||||
- **Build & CI**: Fixed cross-compilation for ARM64, ARMv7, and Windows.
|
||||
|
||||
## [0.1.7] - 2025-10-02
|
||||
|
||||
### Added
|
||||
- **Tabbed Help System**: The help menu is now organized into five tabs for easier navigation.
|
||||
- **Command Aliases**: Added `:o` as a short alias for `:load` / `:open`.
|
||||
|
||||
### Changed
|
||||
- **Session Management**: Improved AI-generated session descriptions.
|
||||
|
||||
## [0.1.6] - 2025-10-02
|
||||
|
||||
### Added
|
||||
- **Platform-Specific Storage**: Sessions are now saved to platform-appropriate directories (e.g., `~/.local/share/owlen` on Linux).
|
||||
- **AI-Generated Session Descriptions**: Conversations can be automatically summarized on save.
|
||||
|
||||
### Changed
|
||||
- **Migration**: Users on older versions can manually move their sessions from `~/.config/owlen/sessions` to the new platform-specific directory.
|
||||
|
||||
## [0.1.4] - 2025-10-01
|
||||
|
||||
### Added
|
||||
- **Multi-Platform Builds**: Pre-built binaries are now provided for Linux (x86_64, aarch64, armv7) and Windows (x86_64).
|
||||
- **AUR Package**: Owlen is now available on the Arch User Repository.
|
||||
|
||||
### Changed
|
||||
- **Build System**: Switched from OpenSSL to rustls for better cross-platform compatibility.
|
||||
121
CODE_OF_CONDUCT.md
Normal file
121
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that are welcoming, open, and respectful.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[security@owlibou.com](mailto:security@owlibou.com). All complaints will be
|
||||
reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interaction in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
121
CONTRIBUTING.md
Normal file
121
CONTRIBUTING.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Contributing to Owlen
|
||||
|
||||
First off, thank you for considering contributing to Owlen! It's people like you that make Owlen such a great tool.
|
||||
|
||||
Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by the [Owlen Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior.
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
This is one of the most helpful ways you can contribute. Before creating a bug report, please check a few things:
|
||||
|
||||
1. **Check the [troubleshooting guide](docs/troubleshooting.md).** Your issue might be a common one with a known solution.
|
||||
2. **Search the existing issues.** It's possible someone has already reported the same bug. If so, add a comment to the existing issue instead of creating a new one.
|
||||
|
||||
When you are creating a bug report, please include as many details as possible. Fill out the required template, the information it asks for helps us resolve issues faster.
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
If you have an idea for a new feature or an improvement to an existing one, we'd love to hear about it. Please provide as much context as you can about what you're trying to achieve.
|
||||
|
||||
### Your First Code Contribution
|
||||
|
||||
Unsure where to begin contributing to Owlen? You can start by looking through `good first issue` and `help wanted` issues.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
The process for submitting a pull request is as follows:
|
||||
|
||||
1. **Fork the repository** and create your branch from `main`.
|
||||
2. **Set up pre-commit hooks** (see [Development Setup](#development-setup) above). This will automatically format and lint your code.
|
||||
3. **Make your changes.**
|
||||
4. **Run the tests.**
|
||||
- `cargo test --all`
|
||||
5. **Commit your changes.** The pre-commit hooks will automatically run `cargo fmt`, `cargo check`, and `cargo clippy`. If you need to bypass the hooks (not recommended), use `git commit --no-verify`.
|
||||
6. **Add a clear, concise commit message.** We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
||||
7. **Push to your fork** and submit a pull request to Owlen's `main` branch.
|
||||
8. **Include a clear description** of the problem and solution. Include the relevant issue number if applicable.
|
||||
|
||||
## Development Setup
|
||||
|
||||
To get started with the codebase, you'll need to have Rust installed. Then, you can clone the repository and build the project:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Owlibou/owlen.git
|
||||
cd owlen
|
||||
cargo build
|
||||
```
|
||||
|
||||
### Pre-commit Hooks
|
||||
|
||||
We use [pre-commit](https://pre-commit.com/) to automatically run formatting and linting checks before each commit. This helps maintain code quality and consistency.
|
||||
|
||||
**Install pre-commit:**
|
||||
|
||||
```sh
|
||||
# Arch Linux
|
||||
sudo pacman -S pre-commit
|
||||
|
||||
# Other Linux/macOS
|
||||
pip install pre-commit
|
||||
|
||||
# Verify installation
|
||||
pre-commit --version
|
||||
```
|
||||
|
||||
**Setup the hooks:**
|
||||
|
||||
```sh
|
||||
cd owlen
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
Once installed, the hooks will automatically run on every commit. You can also run them manually:
|
||||
|
||||
```sh
|
||||
# Run on all files
|
||||
pre-commit run --all-files
|
||||
|
||||
# Run on staged files only
|
||||
pre-commit run
|
||||
```
|
||||
|
||||
The pre-commit hooks will check:
|
||||
- Code formatting (`cargo fmt`)
|
||||
- Compilation (`cargo check`)
|
||||
- Linting (`cargo clippy --all-features`)
|
||||
- General file hygiene (trailing whitespace, EOF newlines, etc.)
|
||||
|
||||
## Coding Style
|
||||
|
||||
- We use `cargo fmt` for automated code formatting. Please run it before committing your changes.
|
||||
- We use `cargo clippy` for linting. Your code should be free of any clippy warnings.
|
||||
|
||||
## Commit Message Conventions
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for our commit messages. This allows for automated changelog generation and makes the project history easier to read.
|
||||
|
||||
The basic format is:
|
||||
|
||||
```
|
||||
<type>[optional scope]: <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci`.
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
feat(provider): add support for Gemini Pro
|
||||
```
|
||||
|
||||
Thank you for your contribution!
|
||||
16
Cargo.toml
16
Cargo.toml
@@ -9,7 +9,7 @@ members = [
|
||||
exclude = []
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.9"
|
||||
edition = "2021"
|
||||
authors = ["Owlibou"]
|
||||
license = "AGPL-3.0"
|
||||
@@ -32,7 +32,7 @@ crossterm = "0.28"
|
||||
tui-textarea = "0.6"
|
||||
|
||||
# HTTP client and JSON handling
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -40,6 +40,17 @@ serde_json = "1.0"
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
nix = "0.29"
|
||||
which = "6.0"
|
||||
tempfile = "3.8"
|
||||
jsonschema = "0.17"
|
||||
aes-gcm = "0.10"
|
||||
ring = "0.17"
|
||||
keyring = "3.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
urlencoding = "2.1"
|
||||
rpassword = "7.3"
|
||||
sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "sqlite", "macros", "uuid", "chrono", "migrate"] }
|
||||
|
||||
# Configuration
|
||||
toml = "0.8"
|
||||
@@ -58,7 +69,6 @@ async-trait = "0.1"
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
|
||||
# Dev dependencies
|
||||
tempfile = "3.8"
|
||||
tokio-test = "0.4"
|
||||
|
||||
# For more keys and their definitions, see https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
1
LICENSE
1
LICENSE
@@ -659,4 +659,3 @@ specific requirements.
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
42
PKGBUILD
42
PKGBUILD
@@ -1,45 +1,49 @@
|
||||
# Maintainer: Owlibou
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlen
|
||||
pkgver=0.1.0
|
||||
pkgver=0.1.9
|
||||
pkgrel=1
|
||||
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
|
||||
arch=('x86_64' 'aarch64')
|
||||
arch=('x86_64')
|
||||
url="https://somegit.dev/Owlibou/owlen"
|
||||
license=('AGPL-3.0-only')
|
||||
license=('AGPL-3.0-or-later')
|
||||
depends=('gcc-libs')
|
||||
makedepends=('cargo' 'git')
|
||||
source=("${pkgname}-${pkgver}.tar.gz::https://somegit.dev/Owlibou/owlen/archive/v${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP') # Update this after first release
|
||||
options=(!lto) # avoid LTO-linked ring symbol drop with lld
|
||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||
sha256sums=('cabb1cfdfc247b5d008c6c5f94e13548bcefeba874aae9a9d45aa95ae1c085ec')
|
||||
|
||||
prepare() {
|
||||
cd "$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
cd $pkgname
|
||||
cargo fetch --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cd $pkgname
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
|
||||
export CARGO_PROFILE_RELEASE_LTO=false
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build --frozen --release --all-features
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cd $pkgname
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
|
||||
cargo test --frozen --all-features
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
cd $pkgname
|
||||
|
||||
# Install binaries
|
||||
install -Dm755 "target/release/owlen" "$pkgdir/usr/bin/owlen"
|
||||
install -Dm755 "target/release/owlen-code" "$pkgdir/usr/bin/owlen-code"
|
||||
|
||||
# Install license
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm755 target/release/owlen "$pkgdir/usr/bin/owlen"
|
||||
install -Dm755 target/release/owlen-code "$pkgdir/usr/bin/owlen-code"
|
||||
|
||||
# Install documentation
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# Install built-in themes for reference
|
||||
install -Dm644 themes/README.md "$pkgdir/usr/share/$pkgname/themes/README.md"
|
||||
for theme in themes/*.toml; do
|
||||
install -Dm644 "$theme" "$pkgdir/usr/share/$pkgname/themes/$(basename $theme)"
|
||||
done
|
||||
}
|
||||
|
||||
268
README.md
268
README.md
@@ -3,17 +3,10 @@
|
||||
> Terminal-native assistant for running local language models with a comfortable TUI.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Alpha Status
|
||||
|
||||
- This project is currently in **alpha** (v0.1.0) and under active development.
|
||||
- Core features are functional but expect occasional bugs and missing polish.
|
||||
- Breaking changes may occur between releases as we refine the API.
|
||||
- Feedback, bug reports, and contributions are very welcome!
|
||||
|
||||
## What Is OWLEN?
|
||||
|
||||
OWLEN is a Rust-powered, terminal-first interface for interacting with local large
|
||||
@@ -21,259 +14,90 @@ language models. It provides a responsive chat workflow that runs against
|
||||
[Ollama](https://ollama.com/) with a focus on developer productivity, vim-style navigation,
|
||||
and seamless session management—all without leaving your terminal.
|
||||
|
||||
## Alpha Status
|
||||
|
||||
This project is currently in **alpha** and under active development. Core features are functional, but expect occasional bugs and breaking changes. Feedback, bug reports, and contributions are very welcome!
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Initial Layout
|
||||

|
||||
|
||||
The OWLEN interface features a clean, multi-panel layout with vim-inspired navigation. See more screenshots in the [`images/`](images/) directory including:
|
||||
- Full chat conversations (`chat_view.png`)
|
||||
- Help menu (`help.png`)
|
||||
- Model selection (`model_select.png`)
|
||||
- Visual selection mode (`select_mode.png`)
|
||||
The OWLEN interface features a clean, multi-panel layout with vim-inspired navigation. See more screenshots in the [`images/`](images/) directory.
|
||||
|
||||
## Features
|
||||
|
||||
### Chat Client (`owlen`)
|
||||
- **Vim-style Navigation** - Normal, editing, visual, and command modes
|
||||
- **Streaming Responses** - Real-time token streaming from Ollama
|
||||
- **Multi-Panel Interface** - Separate panels for chat, thinking content, and input
|
||||
- **Advanced Text Editing** - Multi-line input with `tui-textarea`, history navigation
|
||||
- **Visual Selection & Clipboard** - Yank/paste text across panels
|
||||
- **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation
|
||||
- **Model Management** - Interactive model and provider selection (press `m`)
|
||||
- **Session Management** - Start new conversations, clear history
|
||||
- **Thinking Mode Support** - Dedicated panel for extended reasoning content
|
||||
- **Bracketed Paste** - Safe paste handling for multi-line content
|
||||
|
||||
### Code Client (`owlen-code`) [Experimental]
|
||||
- All chat client features
|
||||
- Optimized system prompt for programming assistance
|
||||
- Foundation for future code-specific features
|
||||
|
||||
### Core Infrastructure
|
||||
- **Modular Architecture** - Separated core logic, TUI components, and providers
|
||||
- **Provider System** - Extensible provider trait (currently: Ollama)
|
||||
- **Session Controller** - Unified conversation and state management
|
||||
- **Configuration Management** - TOML-based config with sensible defaults
|
||||
- **Message Formatting** - Markdown rendering, thinking content extraction
|
||||
- **Async Runtime** - Built on Tokio for efficient streaming
|
||||
- **Vim-style Navigation**: Normal, editing, visual, and command modes.
|
||||
- **Streaming Responses**: Real-time token streaming from Ollama.
|
||||
- **Advanced Text Editing**: Multi-line input, history, and clipboard support.
|
||||
- **Session Management**: Save, load, and manage conversations.
|
||||
- **Theming System**: 10 built-in themes and support for custom themes.
|
||||
- **Modular Architecture**: Extensible provider system (currently Ollama).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Rust 1.75+ and Cargo (`rustup` recommended)
|
||||
- A running Ollama instance with at least one model pulled
|
||||
(defaults to `http://localhost:11434`)
|
||||
- A terminal that supports 256 colors
|
||||
- Rust 1.75+ and Cargo.
|
||||
- A running Ollama instance.
|
||||
- A terminal that supports 256 colors.
|
||||
|
||||
### Clone and Build
|
||||
### Installation
|
||||
|
||||
#### Linux & macOS
|
||||
The recommended way to install on Linux and macOS is to clone the repository and install using `cargo`.
|
||||
|
||||
```bash
|
||||
git clone https://somegit.dev/Owlibou/owlen.git
|
||||
git clone https://github.com/Owlibou/owlen.git
|
||||
cd owlen
|
||||
cargo build --release
|
||||
cargo install --path crates/owlen-cli
|
||||
```
|
||||
**Note for macOS**: While this method works, official binary releases for macOS are planned for the future.
|
||||
|
||||
### Run the Chat Client
|
||||
#### Windows
|
||||
The Windows build has not been thoroughly tested yet. Installation is possible via the same `cargo install` method, but it is considered experimental at this time.
|
||||
|
||||
Make sure Ollama is running, then launch:
|
||||
### Running OWLEN
|
||||
|
||||
Make sure Ollama is running, then launch the application:
|
||||
```bash
|
||||
owlen
|
||||
```
|
||||
If you built from source without installing, you can run it with:
|
||||
```bash
|
||||
./target/release/owlen
|
||||
# or during development:
|
||||
cargo run --bin owlen
|
||||
```
|
||||
|
||||
### (Optional) Try the Code Client
|
||||
|
||||
The coding-focused TUI is experimental:
|
||||
|
||||
```bash
|
||||
cargo build --release --bin owlen-code --features code-client
|
||||
./target/release/owlen-code
|
||||
```
|
||||
|
||||
## Using the TUI
|
||||
|
||||
### Mode System (Vim-inspired)
|
||||
OWLEN uses a modal, vim-inspired interface. Press `?` in Normal mode to view the help screen with all keybindings.
|
||||
|
||||
**Normal Mode** (default):
|
||||
- `i` / `Enter` - Enter editing mode
|
||||
- `a` - Append (move right and enter editing mode)
|
||||
- `A` - Append at end of line
|
||||
- `I` - Insert at start of line
|
||||
- `o` - Insert new line below
|
||||
- `O` - Insert new line above
|
||||
- `v` - Enter visual mode (text selection)
|
||||
- `:` - Enter command mode
|
||||
- `h/j/k/l` - Navigate left/down/up/right
|
||||
- `w/b/e` - Word navigation
|
||||
- `0/$` - Jump to line start/end
|
||||
- `gg` - Jump to top
|
||||
- `G` - Jump to bottom
|
||||
- `Ctrl-d/u` - Half-page scroll
|
||||
- `Ctrl-f/b` - Full-page scroll
|
||||
- `Tab` - Cycle focus between panels
|
||||
- `p` - Paste from clipboard
|
||||
- `dd` - Clear input buffer
|
||||
- `q` - Quit
|
||||
- **Normal Mode**: Navigate with `h/j/k/l`, `w/b`, `gg/G`.
|
||||
- **Editing Mode**: Enter with `i` or `a`. Send messages with `Enter`.
|
||||
- **Command Mode**: Enter with `:`. Access commands like `:quit`, `:save`, `:theme`.
|
||||
|
||||
**Editing Mode**:
|
||||
- `Esc` - Return to normal mode
|
||||
- `Enter` - Send message and return to normal mode
|
||||
- `Ctrl-J` / `Shift-Enter` - Insert newline
|
||||
- `Ctrl-↑/↓` - Navigate input history
|
||||
- Paste events handled automatically
|
||||
## Documentation
|
||||
|
||||
**Visual Mode**:
|
||||
- `j/k/h/l` - Extend selection
|
||||
- `w/b/e` - Word-based selection
|
||||
- `y` - Yank (copy) selection
|
||||
- `d` - Cut selection (Input panel only)
|
||||
- `Esc` - Cancel selection
|
||||
For more detailed information, please refer to the following documents:
|
||||
|
||||
**Command Mode**:
|
||||
- `:q` / `:quit` - Quit application
|
||||
- `:c` / `:clear` - Clear conversation
|
||||
- `:m` / `:model` - Open model selector
|
||||
- `:n` / `:new` - Start new conversation
|
||||
- `:h` / `:help` - Show help
|
||||
|
||||
### Panel Management
|
||||
- Three panels: Chat, Thinking, and Input
|
||||
- `Tab` / `Shift-Tab` - Cycle focus forward/backward
|
||||
- Focused panel receives scroll and navigation commands
|
||||
- Thinking panel appears when extended reasoning is available
|
||||
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: Guidelines for contributing to the project.
|
||||
- **[CHANGELOG.md](CHANGELOG.md)**: A log of changes for each version.
|
||||
- **[docs/architecture.md](docs/architecture.md)**: An overview of the project's architecture.
|
||||
- **[docs/troubleshooting.md](docs/troubleshooting.md)**: Help with common issues.
|
||||
- **[docs/provider-implementation.md](docs/provider-implementation.md)**: A guide for adding new providers.
|
||||
|
||||
## Configuration
|
||||
|
||||
OWLEN stores configuration in `~/.config/owlen/config.toml`. The file is created
|
||||
on first run and can be edited to customize behavior:
|
||||
OWLEN stores its configuration in `~/.config/owlen/config.toml`. This file is created on the first run and can be customized. You can also add custom themes in `~/.config/owlen/themes/`.
|
||||
|
||||
```toml
|
||||
[general]
|
||||
default_model = "llama3.2:latest"
|
||||
default_provider = "ollama"
|
||||
enable_streaming = true
|
||||
project_context_file = "OWLEN.md"
|
||||
|
||||
[providers.ollama]
|
||||
provider_type = "ollama"
|
||||
base_url = "http://localhost:11434"
|
||||
timeout = 300
|
||||
```
|
||||
|
||||
Configuration is automatically saved when you change models or providers.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
owlen/
|
||||
├── crates/
|
||||
│ ├── owlen-core/ # Core types, session management, shared UI components
|
||||
│ ├── owlen-ollama/ # Ollama provider implementation
|
||||
│ ├── owlen-tui/ # TUI components (chat_app, code_app, rendering)
|
||||
│ └── owlen-cli/ # Binary entry points (owlen, owlen-code)
|
||||
├── LICENSE # AGPL-3.0 License
|
||||
├── Cargo.toml # Workspace configuration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Architecture Highlights
|
||||
- **owlen-core**: Provider-agnostic core with session controller, UI primitives (AutoScroll, InputMode, FocusedPanel), and shared utilities
|
||||
- **owlen-tui**: Ratatui-based UI implementation with vim-style modal editing
|
||||
- **Separation of Concerns**: Clean boundaries between business logic, presentation, and provider implementations
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# Debug build
|
||||
cargo build
|
||||
|
||||
# Release build
|
||||
cargo build --release
|
||||
|
||||
# Build with all features
|
||||
cargo build --all-features
|
||||
|
||||
# Run tests
|
||||
cargo test
|
||||
|
||||
# Check code
|
||||
cargo clippy
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
### Development Notes
|
||||
- Standard Rust workflows apply (`cargo fmt`, `cargo clippy`, `cargo test`)
|
||||
- Codebase uses async Rust (`tokio`) for event handling and streaming
|
||||
- Configuration is cached in `~/.config/owlen` (wipe to reset)
|
||||
- UI components are extensively tested in `owlen-core/src/ui.rs`
|
||||
See the [themes/README.md](themes/README.md) for more details on theming.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Completed ✓
|
||||
- [x] Streaming responses with real-time display
|
||||
- [x] Autoscroll and viewport management
|
||||
- [x] Push user message before loading LLM response
|
||||
- [x] Thinking mode support with dedicated panel
|
||||
- [x] Vim-style modal editing (Normal, Visual, Command modes)
|
||||
- [x] Multi-panel focus management
|
||||
- [x] Text selection and clipboard functionality
|
||||
- [x] Comprehensive keyboard navigation
|
||||
- [x] Bracketed paste support
|
||||
|
||||
### In Progress
|
||||
- [ ] Theming options and color customization
|
||||
- [ ] Enhanced configuration UX (in-app settings)
|
||||
- [ ] Chat history management (save/load/export)
|
||||
|
||||
### Planned
|
||||
- [ ] Code Client Enhancement
|
||||
- [ ] In-project code navigation
|
||||
- [ ] Syntax highlighting for code blocks
|
||||
- [ ] File tree browser integration
|
||||
- [ ] Project-aware context management
|
||||
- [ ] Code snippets and templates
|
||||
- [ ] Additional LLM Providers
|
||||
- [ ] OpenAI API support
|
||||
- [ ] Anthropic Claude support
|
||||
- [ ] Local model providers (llama.cpp, etc.)
|
||||
- [ ] Advanced Features
|
||||
- [ ] Conversation search and filtering
|
||||
- [ ] Multi-session management
|
||||
- [ ] Export conversations (Markdown, JSON)
|
||||
- [ ] Custom keybindings
|
||||
- [ ] Plugin system
|
||||
We are actively working on enhancing the code client, adding more providers (OpenAI, Anthropic), and improving the overall user experience. See the [Roadmap section in the old README](https://github.com/Owlibou/owlen/blob/main/README.md?plain=1#L295) for more details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Here's how to get started:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Make your changes and add tests
|
||||
4. Run `cargo fmt` and `cargo clippy`
|
||||
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
7. Open a Pull Request
|
||||
|
||||
Please open an issue first for significant changes to discuss the approach.
|
||||
Contributions are highly welcome! Please see our **[Contributing Guide](CONTRIBUTING.md)** for details on how to get started, including our code style, commit conventions, and pull request process.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with:
|
||||
- [ratatui](https://ratatui.rs/) - Terminal UI framework
|
||||
- [crossterm](https://github.com/crossterm-rs/crossterm) - Cross-platform terminal manipulation
|
||||
- [tokio](https://tokio.rs/) - Async runtime
|
||||
- [Ollama](https://ollama.com/) - Local LLM runtime
|
||||
|
||||
---
|
||||
|
||||
**Status**: Alpha v0.1.0 | **License**: AGPL-3.0 | **Made with Rust** 🦀
|
||||
This project is licensed under the GNU Affero General Public License v3.0. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
19
SECURITY.md
Normal file
19
SECURITY.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We are currently in a pre-release phase, so only the latest version is actively supported. As we move towards a 1.0 release, this policy will be updated with specific version support.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| < 1.0 | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
The Owlen team and community take all security vulnerabilities seriously. Thank you for improving the security of our project. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
|
||||
|
||||
To report a security vulnerability, please email the project lead at [security@owlibou.com](mailto:security@owlibou.com) with a detailed description of the issue, the steps to reproduce it, and any affected versions.
|
||||
|
||||
You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible, depending on the complexity of the issue.
|
||||
|
||||
Please do not report security vulnerabilities through public GitHub issues.
|
||||
5
crates/owlen-anthropic/README.md
Normal file
5
crates/owlen-anthropic/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Owlen Anthropic
|
||||
|
||||
This crate is a placeholder for a future `owlen-core::Provider` implementation for the Anthropic (Claude) API.
|
||||
|
||||
This provider is not yet implemented. Contributions are welcome!
|
||||
15
crates/owlen-cli/README.md
Normal file
15
crates/owlen-cli/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Owlen CLI
|
||||
|
||||
This crate is the command-line entry point for the Owlen application.
|
||||
|
||||
It is responsible for:
|
||||
|
||||
- Parsing command-line arguments.
|
||||
- Loading the configuration.
|
||||
- Initializing the providers.
|
||||
- Starting the `owlen-tui` application.
|
||||
|
||||
There are two binaries:
|
||||
|
||||
- `owlen`: The main chat application.
|
||||
- `owlen-code`: A specialized version for code-related tasks.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Arg, Command};
|
||||
use owlen_core::session::SessionController;
|
||||
use owlen_core::{session::SessionController, storage::StorageManager};
|
||||
use owlen_ollama::OllamaProvider;
|
||||
use owlen_tui::{config, ui, AppState, CodeApp, Event, EventHandler, SessionEvent};
|
||||
use std::io;
|
||||
@@ -37,14 +37,27 @@ async fn main() -> Result<()> {
|
||||
config.general.default_model = Some(model.clone());
|
||||
}
|
||||
|
||||
let provider_cfg = config::ensure_ollama_config(&mut config).clone();
|
||||
let provider_name = config.general.default_provider.clone();
|
||||
let provider_cfg = config::ensure_provider_config(&mut config, &provider_name).clone();
|
||||
|
||||
let provider_type = provider_cfg.provider_type.to_ascii_lowercase();
|
||||
if provider_type != "ollama" && provider_type != "ollama-cloud" {
|
||||
anyhow::bail!(
|
||||
"Unsupported provider type '{}' configured for provider '{}'",
|
||||
provider_cfg.provider_type,
|
||||
provider_name
|
||||
);
|
||||
}
|
||||
|
||||
let provider = Arc::new(OllamaProvider::from_config(
|
||||
&provider_cfg,
|
||||
Some(&config.general),
|
||||
)?);
|
||||
|
||||
let controller = SessionController::new(provider, config.clone());
|
||||
let (mut app, mut session_rx) = CodeApp::new(controller);
|
||||
let storage = Arc::new(StorageManager::new().await?);
|
||||
// Code client - code execution tools enabled
|
||||
let controller = SessionController::new(provider, config.clone(), storage.clone(), true)?;
|
||||
let (mut app, mut session_rx) = CodeApp::new(controller).await?;
|
||||
app.inner_mut().initialize_models().await?;
|
||||
|
||||
let cancellation_token = CancellationToken::new();
|
||||
@@ -87,8 +100,21 @@ async fn run_app(
|
||||
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
// Advance loading animation frame
|
||||
app.inner_mut().advance_loading_animation();
|
||||
|
||||
terminal.draw(|f| ui::render_chat(f, app.inner_mut()))?;
|
||||
|
||||
// Process any pending LLM requests AFTER UI has been drawn
|
||||
if let Err(e) = app.inner_mut().process_pending_llm_request().await {
|
||||
eprintln!("Error processing LLM request: {}", e);
|
||||
}
|
||||
|
||||
// Process any pending tool executions AFTER UI has been drawn
|
||||
if let Err(e) = app.inner_mut().process_pending_tool_execution().await {
|
||||
eprintln!("Error processing tool execution: {}", e);
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
Some(event) = event_rx.recv() => {
|
||||
if let AppState::Quit = app.handle_event(event).await? {
|
||||
@@ -98,6 +124,10 @@ async fn run_app(
|
||||
Some(session_event) = session_rx.recv() => {
|
||||
app.handle_session_event(session_event)?;
|
||||
}
|
||||
// Add a timeout to keep the animation going even when there are no events
|
||||
_ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
|
||||
// This will cause the loop to continue and advance the animation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Arg, Command};
|
||||
use owlen_core::session::SessionController;
|
||||
use owlen_core::{session::SessionController, storage::StorageManager};
|
||||
use owlen_ollama::OllamaProvider;
|
||||
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
|
||||
use std::io;
|
||||
@@ -38,14 +38,27 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
// Prepare provider from configuration
|
||||
let provider_cfg = config::ensure_ollama_config(&mut config).clone();
|
||||
let provider_name = config.general.default_provider.clone();
|
||||
let provider_cfg = config::ensure_provider_config(&mut config, &provider_name).clone();
|
||||
|
||||
let provider_type = provider_cfg.provider_type.to_ascii_lowercase();
|
||||
if provider_type != "ollama" && provider_type != "ollama-cloud" {
|
||||
anyhow::bail!(
|
||||
"Unsupported provider type '{}' configured for provider '{}'",
|
||||
provider_cfg.provider_type,
|
||||
provider_name
|
||||
);
|
||||
}
|
||||
|
||||
let provider = Arc::new(OllamaProvider::from_config(
|
||||
&provider_cfg,
|
||||
Some(&config.general),
|
||||
)?);
|
||||
|
||||
let controller = SessionController::new(provider, config.clone());
|
||||
let (mut app, mut session_rx) = ChatApp::new(controller);
|
||||
let storage = Arc::new(StorageManager::new().await?);
|
||||
// Chat client - code execution tools disabled (only available in code client)
|
||||
let controller = SessionController::new(provider, config.clone(), storage.clone(), false)?;
|
||||
let (mut app, mut session_rx) = ChatApp::new(controller).await?;
|
||||
app.initialize_models().await?;
|
||||
|
||||
// Event infrastructure
|
||||
@@ -104,7 +117,14 @@ async fn run_app(
|
||||
terminal.draw(|f| ui::render_chat(f, app))?;
|
||||
|
||||
// Process any pending LLM requests AFTER UI has been drawn
|
||||
app.process_pending_llm_request().await?;
|
||||
if let Err(e) = app.process_pending_llm_request().await {
|
||||
eprintln!("Error processing LLM request: {}", e);
|
||||
}
|
||||
|
||||
// Process any pending tool executions AFTER UI has been drawn
|
||||
if let Err(e) = app.process_pending_tool_execution().await {
|
||||
eprintln!("Error processing tool execution: {}", e);
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
Some(event) = event_rx.recv() => {
|
||||
|
||||
@@ -23,6 +23,22 @@ futures = "0.3.28"
|
||||
async-trait = "0.1.73"
|
||||
toml = "0.8.0"
|
||||
shellexpand = "3.1.0"
|
||||
dirs = "5.0"
|
||||
ratatui = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
jsonschema = { workspace = true }
|
||||
which = { workspace = true }
|
||||
nix = { workspace = true }
|
||||
aes-gcm = { workspace = true }
|
||||
ring = { workspace = true }
|
||||
keyring = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
rpassword = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
duckduckgo = "0.2.0"
|
||||
reqwest = { workspace = true, features = ["default"] }
|
||||
reqwest_011 = { version = "0.11", package = "reqwest" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
12
crates/owlen-core/README.md
Normal file
12
crates/owlen-core/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Owlen Core
|
||||
|
||||
This crate provides the core abstractions and data structures for the Owlen ecosystem.
|
||||
|
||||
It defines the essential traits and types that enable communication with various LLM providers, manage sessions, and handle configuration.
|
||||
|
||||
## Key Components
|
||||
|
||||
- **`Provider` trait**: The fundamental abstraction for all LLM providers. Implement this trait to add support for a new provider.
|
||||
- **`Session`**: Represents a single conversation, managing message history and context.
|
||||
- **`Model`**: Defines the structure for LLM models, including their names and properties.
|
||||
- **Configuration**: Handles loading and parsing of the application's configuration.
|
||||
12
crates/owlen-core/migrations/0001_create_conversations.sql
Normal file
12
crates/owlen-core/migrations/0001_create_conversations.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
model TEXT NOT NULL,
|
||||
message_count INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at DESC);
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS secure_items (
|
||||
key TEXT PRIMARY KEY,
|
||||
nonce BLOB NOT NULL,
|
||||
ciphertext BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -26,19 +26,24 @@ pub struct Config {
|
||||
/// Input handling preferences
|
||||
#[serde(default)]
|
||||
pub input: InputSettings,
|
||||
/// Privacy controls for tooling and network usage
|
||||
#[serde(default)]
|
||||
pub privacy: PrivacySettings,
|
||||
/// Security controls for sandboxing and resource limits
|
||||
#[serde(default)]
|
||||
pub security: SecuritySettings,
|
||||
/// Per-tool configuration toggles
|
||||
#[serde(default)]
|
||||
pub tools: ToolSettings,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let mut providers = HashMap::new();
|
||||
providers.insert("ollama".to_string(), default_ollama_provider_config());
|
||||
providers.insert(
|
||||
"ollama".to_string(),
|
||||
ProviderConfig {
|
||||
provider_type: "ollama".to_string(),
|
||||
base_url: Some("http://localhost:11434".to_string()),
|
||||
api_key: None,
|
||||
extra: HashMap::new(),
|
||||
},
|
||||
"ollama-cloud".to_string(),
|
||||
default_ollama_cloud_provider_config(),
|
||||
);
|
||||
|
||||
Self {
|
||||
@@ -47,6 +52,9 @@ impl Default for Config {
|
||||
ui: UiSettings::default(),
|
||||
storage: StorageSettings::default(),
|
||||
input: InputSettings::default(),
|
||||
privacy: PrivacySettings::default(),
|
||||
security: SecuritySettings::default(),
|
||||
tools: ToolSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,17 +128,26 @@ impl Config {
|
||||
self.general.default_provider = "ollama".to_string();
|
||||
}
|
||||
|
||||
if !self.providers.contains_key("ollama") {
|
||||
self.providers.insert(
|
||||
"ollama".to_string(),
|
||||
ProviderConfig {
|
||||
provider_type: "ollama".to_string(),
|
||||
base_url: Some("http://localhost:11434".to_string()),
|
||||
api_key: None,
|
||||
extra: HashMap::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
ensure_provider_config(self, "ollama");
|
||||
ensure_provider_config(self, "ollama-cloud");
|
||||
}
|
||||
}
|
||||
|
||||
fn default_ollama_provider_config() -> ProviderConfig {
|
||||
ProviderConfig {
|
||||
provider_type: "ollama".to_string(),
|
||||
base_url: Some("http://localhost:11434".to_string()),
|
||||
api_key: None,
|
||||
extra: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_ollama_cloud_provider_config() -> ProviderConfig {
|
||||
ProviderConfig {
|
||||
provider_type: "ollama-cloud".to_string(),
|
||||
base_url: Some("https://ollama.com".to_string()),
|
||||
api_key: None,
|
||||
extra: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +202,154 @@ impl Default for GeneralSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Privacy controls governing network access and storage
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrivacySettings {
|
||||
#[serde(default = "PrivacySettings::default_remote_search")]
|
||||
pub enable_remote_search: bool,
|
||||
#[serde(default)]
|
||||
pub cache_web_results: bool,
|
||||
#[serde(default)]
|
||||
pub retain_history_days: u32,
|
||||
#[serde(default = "PrivacySettings::default_require_consent")]
|
||||
pub require_consent_per_session: bool,
|
||||
#[serde(default = "PrivacySettings::default_encrypt_local_data")]
|
||||
pub encrypt_local_data: bool,
|
||||
}
|
||||
|
||||
impl PrivacySettings {
|
||||
const fn default_remote_search() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
const fn default_require_consent() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn default_encrypt_local_data() -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PrivacySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_remote_search: Self::default_remote_search(),
|
||||
cache_web_results: false,
|
||||
retain_history_days: 0,
|
||||
require_consent_per_session: Self::default_require_consent(),
|
||||
encrypt_local_data: Self::default_encrypt_local_data(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Security settings that constrain tool execution
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecuritySettings {
|
||||
#[serde(default = "SecuritySettings::default_enable_sandboxing")]
|
||||
pub enable_sandboxing: bool,
|
||||
#[serde(default = "SecuritySettings::default_timeout")]
|
||||
pub sandbox_timeout_seconds: u64,
|
||||
#[serde(default = "SecuritySettings::default_max_memory")]
|
||||
pub max_memory_mb: u64,
|
||||
#[serde(default = "SecuritySettings::default_allowed_tools")]
|
||||
pub allowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
impl SecuritySettings {
|
||||
const fn default_enable_sandboxing() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn default_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
const fn default_max_memory() -> u64 {
|
||||
512
|
||||
}
|
||||
|
||||
fn default_allowed_tools() -> Vec<String> {
|
||||
vec!["web_search".to_string(), "code_exec".to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SecuritySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_sandboxing: Self::default_enable_sandboxing(),
|
||||
sandbox_timeout_seconds: Self::default_timeout(),
|
||||
max_memory_mb: Self::default_max_memory(),
|
||||
allowed_tools: Self::default_allowed_tools(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-tool configuration toggles
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ToolSettings {
|
||||
#[serde(default)]
|
||||
pub web_search: WebSearchToolConfig,
|
||||
#[serde(default)]
|
||||
pub code_exec: CodeExecToolConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebSearchToolConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub api_key: String,
|
||||
#[serde(default = "WebSearchToolConfig::default_max_results")]
|
||||
pub max_results: u32,
|
||||
}
|
||||
|
||||
impl WebSearchToolConfig {
|
||||
const fn default_max_results() -> u32 {
|
||||
5
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebSearchToolConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
api_key: String::new(),
|
||||
max_results: Self::default_max_results(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CodeExecToolConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "CodeExecToolConfig::default_allowed_languages")]
|
||||
pub allowed_languages: Vec<String>,
|
||||
#[serde(default = "CodeExecToolConfig::default_timeout")]
|
||||
pub timeout_seconds: u64,
|
||||
}
|
||||
|
||||
impl CodeExecToolConfig {
|
||||
fn default_allowed_languages() -> Vec<String> {
|
||||
vec!["python".to_string(), "javascript".to_string()]
|
||||
}
|
||||
|
||||
const fn default_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CodeExecToolConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
allowed_languages: Self::default_allowed_languages(),
|
||||
timeout_seconds: Self::default_timeout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UI preferences that consumers can respect as needed
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UiSettings {
|
||||
@@ -202,7 +367,7 @@ pub struct UiSettings {
|
||||
|
||||
impl UiSettings {
|
||||
fn default_theme() -> String {
|
||||
"default".to_string()
|
||||
"default_dark".to_string()
|
||||
}
|
||||
|
||||
fn default_word_wrap() -> bool {
|
||||
@@ -238,18 +403,20 @@ impl Default for UiSettings {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StorageSettings {
|
||||
#[serde(default = "StorageSettings::default_conversation_dir")]
|
||||
pub conversation_dir: String,
|
||||
pub conversation_dir: Option<String>,
|
||||
#[serde(default = "StorageSettings::default_auto_save")]
|
||||
pub auto_save_sessions: bool,
|
||||
#[serde(default = "StorageSettings::default_max_sessions")]
|
||||
pub max_saved_sessions: usize,
|
||||
#[serde(default = "StorageSettings::default_session_timeout")]
|
||||
pub session_timeout_minutes: u64,
|
||||
#[serde(default = "StorageSettings::default_generate_descriptions")]
|
||||
pub generate_descriptions: bool,
|
||||
}
|
||||
|
||||
impl StorageSettings {
|
||||
fn default_conversation_dir() -> String {
|
||||
"~/.local/share/owlen/conversations".to_string()
|
||||
fn default_conversation_dir() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn default_auto_save() -> bool {
|
||||
@@ -264,19 +431,35 @@ impl StorageSettings {
|
||||
120
|
||||
}
|
||||
|
||||
fn default_generate_descriptions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Resolve storage directory path
|
||||
/// Uses platform-specific data directory if not explicitly configured:
|
||||
/// - Linux: ~/.local/share/owlen/sessions
|
||||
/// - Windows: %APPDATA%\owlen\sessions
|
||||
/// - macOS: ~/Library/Application Support/owlen/sessions
|
||||
pub fn conversation_path(&self) -> PathBuf {
|
||||
PathBuf::from(shellexpand::tilde(&self.conversation_dir).as_ref())
|
||||
if let Some(ref dir) = self.conversation_dir {
|
||||
PathBuf::from(shellexpand::tilde(dir).as_ref())
|
||||
} else {
|
||||
// Use platform-specific data directory
|
||||
dirs::data_local_dir()
|
||||
.map(|d| d.join("owlen").join("sessions"))
|
||||
.unwrap_or_else(|| PathBuf::from("./owlen_sessions"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StorageSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
conversation_dir: Self::default_conversation_dir(),
|
||||
conversation_dir: None, // Use platform-specific defaults
|
||||
auto_save_sessions: Self::default_auto_save(),
|
||||
max_saved_sessions: Self::default_max_sessions(),
|
||||
session_timeout_minutes: Self::default_session_timeout(),
|
||||
generate_descriptions: Self::default_generate_descriptions(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,18 +508,99 @@ impl Default for InputSettings {
|
||||
|
||||
/// Convenience accessor for an Ollama provider entry, creating a default if missing
|
||||
pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig {
|
||||
config
|
||||
.providers
|
||||
.entry("ollama".to_string())
|
||||
.or_insert_with(|| ProviderConfig {
|
||||
provider_type: "ollama".to_string(),
|
||||
base_url: Some("http://localhost:11434".to_string()),
|
||||
api_key: None,
|
||||
extra: HashMap::new(),
|
||||
})
|
||||
ensure_provider_config(config, "ollama")
|
||||
}
|
||||
|
||||
/// Ensure a provider configuration exists for the requested provider name
|
||||
pub fn ensure_provider_config<'a>(
|
||||
config: &'a mut Config,
|
||||
provider_name: &str,
|
||||
) -> &'a ProviderConfig {
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
match config.providers.entry(provider_name.to_string()) {
|
||||
Entry::Occupied(entry) => entry.into_mut(),
|
||||
Entry::Vacant(entry) => {
|
||||
let default = match provider_name {
|
||||
"ollama-cloud" => default_ollama_cloud_provider_config(),
|
||||
"ollama" => default_ollama_provider_config(),
|
||||
other => ProviderConfig {
|
||||
provider_type: other.to_string(),
|
||||
base_url: None,
|
||||
api_key: None,
|
||||
extra: HashMap::new(),
|
||||
},
|
||||
};
|
||||
entry.insert(default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate absolute timeout for session data based on configuration
|
||||
pub fn session_timeout(config: &Config) -> Duration {
|
||||
Duration::from_secs(config.storage.session_timeout_minutes.max(1) * 60)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_storage_platform_specific_paths() {
|
||||
let config = Config::default();
|
||||
let path = config.storage.conversation_path();
|
||||
|
||||
// Verify it contains owlen/sessions
|
||||
assert!(path.to_string_lossy().contains("owlen"));
|
||||
assert!(path.to_string_lossy().contains("sessions"));
|
||||
|
||||
// Platform-specific checks
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Linux should use ~/.local/share/owlen/sessions
|
||||
assert!(path.to_string_lossy().contains(".local/share"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows should use AppData
|
||||
assert!(path.to_string_lossy().contains("AppData"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// macOS should use ~/Library/Application Support
|
||||
assert!(path
|
||||
.to_string_lossy()
|
||||
.contains("Library/Application Support"));
|
||||
}
|
||||
|
||||
println!("Config conversation path: {}", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_custom_path() {
|
||||
let mut config = Config::default();
|
||||
config.storage.conversation_dir = Some("~/custom/path".to_string());
|
||||
|
||||
let path = config.storage.conversation_path();
|
||||
assert!(path.to_string_lossy().contains("custom/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_contains_local_and_cloud_providers() {
|
||||
let config = Config::default();
|
||||
assert!(config.providers.contains_key("ollama"));
|
||||
assert!(config.providers.contains_key("ollama-cloud"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_provider_config_backfills_cloud_defaults() {
|
||||
let mut config = Config::default();
|
||||
config.providers.remove("ollama-cloud");
|
||||
|
||||
let cloud = ensure_provider_config(&mut config, "ollama-cloud");
|
||||
assert_eq!(cloud.provider_type, "ollama-cloud");
|
||||
assert_eq!(cloud.base_url.as_deref(), Some("https://ollama.com"));
|
||||
}
|
||||
}
|
||||
|
||||
172
crates/owlen-core/src/consent.rs
Normal file
172
crates/owlen-core/src/consent.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::encryption::VaultHandle;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConsentRequest {
|
||||
pub tool_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ConsentRecord {
|
||||
pub tool_name: String,
|
||||
pub granted: bool,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub data_types: Vec<String>,
|
||||
pub external_endpoints: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct ConsentManager {
|
||||
records: HashMap<String, ConsentRecord>,
|
||||
}
|
||||
|
||||
impl ConsentManager {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Load consent records from vault storage
|
||||
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self {
|
||||
let guard = vault.lock().expect("Vault mutex poisoned");
|
||||
if let Some(consent_data) = guard.settings().get("consent_records") {
|
||||
if let Ok(records) =
|
||||
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
|
||||
{
|
||||
return Self { records };
|
||||
}
|
||||
}
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Persist consent records to vault storage
|
||||
pub fn persist_to_vault(&self, vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Result<()> {
|
||||
let mut guard = vault.lock().expect("Vault mutex poisoned");
|
||||
let consent_json = serde_json::to_value(&self.records)?;
|
||||
guard
|
||||
.settings_mut()
|
||||
.insert("consent_records".to_string(), consent_json);
|
||||
guard.persist()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn request_consent(
|
||||
&mut self,
|
||||
tool_name: &str,
|
||||
data_types: Vec<String>,
|
||||
endpoints: Vec<String>,
|
||||
) -> Result<bool> {
|
||||
if let Some(existing) = self.records.get(tool_name) {
|
||||
return Ok(existing.granted);
|
||||
}
|
||||
|
||||
let consent = self.show_consent_dialog(tool_name, &data_types, &endpoints)?;
|
||||
|
||||
let record = ConsentRecord {
|
||||
tool_name: tool_name.to_string(),
|
||||
granted: consent,
|
||||
timestamp: Utc::now(),
|
||||
data_types,
|
||||
external_endpoints: endpoints,
|
||||
};
|
||||
|
||||
self.records.insert(tool_name.to_string(), record);
|
||||
// Note: Caller should persist to vault after this call
|
||||
Ok(consent)
|
||||
}
|
||||
|
||||
/// Grant consent programmatically (for TUI or automated flows)
|
||||
pub fn grant_consent(
|
||||
&mut self,
|
||||
tool_name: &str,
|
||||
data_types: Vec<String>,
|
||||
endpoints: Vec<String>,
|
||||
) {
|
||||
let record = ConsentRecord {
|
||||
tool_name: tool_name.to_string(),
|
||||
granted: true,
|
||||
timestamp: Utc::now(),
|
||||
data_types,
|
||||
external_endpoints: endpoints,
|
||||
};
|
||||
self.records.insert(tool_name.to_string(), record);
|
||||
}
|
||||
|
||||
/// Check if consent is needed (returns None if already granted, Some(info) if needed)
|
||||
pub fn check_consent_needed(&self, tool_name: &str) -> Option<ConsentRequest> {
|
||||
if self.has_consent(tool_name) {
|
||||
None
|
||||
} else {
|
||||
Some(ConsentRequest {
|
||||
tool_name: tool_name.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_consent(&self, tool_name: &str) -> bool {
|
||||
self.records
|
||||
.get(tool_name)
|
||||
.map(|record| record.granted)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn revoke_consent(&mut self, tool_name: &str) {
|
||||
if let Some(record) = self.records.get_mut(tool_name) {
|
||||
record.granted = false;
|
||||
record.timestamp = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_all_consent(&mut self) {
|
||||
self.records.clear();
|
||||
}
|
||||
|
||||
/// Check if consent is needed for a tool (non-blocking)
|
||||
/// Returns Some with consent details if needed, None if already granted
|
||||
pub fn check_if_consent_needed(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
data_types: Vec<String>,
|
||||
endpoints: Vec<String>,
|
||||
) -> Option<(String, Vec<String>, Vec<String>)> {
|
||||
if self.has_consent(tool_name) {
|
||||
return None;
|
||||
}
|
||||
Some((tool_name.to_string(), data_types, endpoints))
|
||||
}
|
||||
|
||||
fn show_consent_dialog(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
data_types: &[String],
|
||||
endpoints: &[String],
|
||||
) -> Result<bool> {
|
||||
// TEMPORARY: Auto-grant consent when not in a proper terminal (TUI mode)
|
||||
// TODO: Integrate consent UI into the TUI event loop
|
||||
use std::io::IsTerminal;
|
||||
if !io::stdin().is_terminal() || std::env::var("OWLEN_AUTO_CONSENT").is_ok() {
|
||||
eprintln!("Auto-granting consent for {} (TUI mode)", tool_name);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
println!("=== PRIVACY CONSENT REQUIRED ===");
|
||||
println!("Tool: {}", tool_name);
|
||||
println!("Data to be sent: {}", data_types.join(", "));
|
||||
println!("External endpoints: {}", endpoints.join(", "));
|
||||
println!("Do you consent to this data transmission? (y/N)");
|
||||
|
||||
print!("> ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
|
||||
Ok(matches!(input.trim().to_lowercase().as_str(), "y" | "yes"))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::storage::StorageManager;
|
||||
use crate::types::{Conversation, Message};
|
||||
use crate::Result;
|
||||
use serde_json::{Number, Value};
|
||||
@@ -47,8 +48,8 @@ impl ConversationManager {
|
||||
&self.active
|
||||
}
|
||||
|
||||
/// Mutable access to the active conversation (auto refreshing indexes afterwards)
|
||||
fn active_mut(&mut self) -> &mut Conversation {
|
||||
/// Public mutable access to the active conversation
|
||||
pub fn active_mut(&mut self) -> &mut Conversation {
|
||||
&mut self.active
|
||||
}
|
||||
|
||||
@@ -212,6 +213,25 @@ impl ConversationManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set tool calls on a streaming message
|
||||
pub fn set_tool_calls_on_message(
|
||||
&mut self,
|
||||
message_id: Uuid,
|
||||
tool_calls: Vec<crate::types::ToolCall>,
|
||||
) -> Result<()> {
|
||||
let index = self
|
||||
.message_index
|
||||
.get(&message_id)
|
||||
.copied()
|
||||
.ok_or_else(|| crate::Error::Unknown(format!("Unknown message id: {message_id}")))?;
|
||||
|
||||
if let Some(message) = self.active_mut().messages.get_mut(index) {
|
||||
message.tool_calls = Some(tool_calls);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the active model (used when user changes model mid session)
|
||||
pub fn set_model(&mut self, model: impl Into<String>) {
|
||||
self.active.model = model.into();
|
||||
@@ -264,6 +284,43 @@ impl ConversationManager {
|
||||
fn stream_reset(&mut self) {
|
||||
self.streaming.clear();
|
||||
}
|
||||
|
||||
/// Save the active conversation to disk
|
||||
pub async fn save_active(
|
||||
&self,
|
||||
storage: &StorageManager,
|
||||
name: Option<String>,
|
||||
) -> Result<Uuid> {
|
||||
storage.save_conversation(&self.active, name).await?;
|
||||
Ok(self.active.id)
|
||||
}
|
||||
|
||||
/// Save the active conversation to disk with a description
|
||||
pub async fn save_active_with_description(
|
||||
&self,
|
||||
storage: &StorageManager,
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<Uuid> {
|
||||
storage
|
||||
.save_conversation_with_description(&self.active, name, description)
|
||||
.await?;
|
||||
Ok(self.active.id)
|
||||
}
|
||||
|
||||
/// Load a conversation from storage and make it active
|
||||
pub async fn load_saved(&mut self, storage: &StorageManager, id: Uuid) -> Result<()> {
|
||||
let conversation = storage.load_conversation(id).await?;
|
||||
self.load(conversation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all saved sessions
|
||||
pub async fn list_saved_sessions(
|
||||
storage: &StorageManager,
|
||||
) -> Result<Vec<crate::storage::SessionMeta>> {
|
||||
storage.list_sessions().await
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamingMetadata {
|
||||
|
||||
69
crates/owlen-core/src/credentials.rs
Normal file
69
crates/owlen-core/src/credentials.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{storage::StorageManager, Error, Result};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ApiCredentials {
|
||||
pub api_key: String,
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
pub struct CredentialManager {
|
||||
storage: Arc<StorageManager>,
|
||||
master_key: Arc<Vec<u8>>,
|
||||
namespace: String,
|
||||
}
|
||||
|
||||
impl CredentialManager {
|
||||
pub fn new(storage: Arc<StorageManager>, master_key: Arc<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
storage,
|
||||
master_key,
|
||||
namespace: "owlen".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn namespaced_key(&self, tool_name: &str) -> String {
|
||||
format!("{}_{}", self.namespace, tool_name)
|
||||
}
|
||||
|
||||
pub async fn store_credentials(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
credentials: &ApiCredentials,
|
||||
) -> Result<()> {
|
||||
let key = self.namespaced_key(tool_name);
|
||||
let payload = serde_json::to_vec(credentials).map_err(|e| {
|
||||
Error::Storage(format!(
|
||||
"Failed to serialize credentials for secure storage: {e}"
|
||||
))
|
||||
})?;
|
||||
self.storage
|
||||
.store_secure_item(&key, &payload, &self.master_key)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_credentials(&self, tool_name: &str) -> Result<Option<ApiCredentials>> {
|
||||
let key = self.namespaced_key(tool_name);
|
||||
match self
|
||||
.storage
|
||||
.load_secure_item(&key, &self.master_key)
|
||||
.await?
|
||||
{
|
||||
Some(bytes) => {
|
||||
let creds = serde_json::from_slice(&bytes).map_err(|e| {
|
||||
Error::Storage(format!("Failed to deserialize stored credentials: {e}"))
|
||||
})?;
|
||||
Ok(Some(creds))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_credentials(&self, tool_name: &str) -> Result<()> {
|
||||
let key = self.namespaced_key(tool_name);
|
||||
self.storage.delete_secure_item(&key).await
|
||||
}
|
||||
}
|
||||
241
crates/owlen-core/src/encryption.rs
Normal file
241
crates/owlen-core/src/encryption.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use ring::digest;
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub struct EncryptedStorage {
|
||||
cipher: Aes256Gcm,
|
||||
storage_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct EncryptedData {
|
||||
nonce: [u8; 12],
|
||||
ciphertext: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct VaultData {
|
||||
pub master_key: Vec<u8>,
|
||||
#[serde(default)]
|
||||
pub settings: HashMap<String, JsonValue>,
|
||||
}
|
||||
|
||||
pub struct VaultHandle {
|
||||
storage: EncryptedStorage,
|
||||
pub data: VaultData,
|
||||
}
|
||||
|
||||
impl VaultHandle {
|
||||
pub fn master_key(&self) -> &[u8] {
|
||||
&self.data.master_key
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> &HashMap<String, JsonValue> {
|
||||
&self.data.settings
|
||||
}
|
||||
|
||||
pub fn settings_mut(&mut self) -> &mut HashMap<String, JsonValue> {
|
||||
&mut self.data.settings
|
||||
}
|
||||
|
||||
pub fn persist(&self) -> Result<()> {
|
||||
self.storage.store(&self.data)
|
||||
}
|
||||
}
|
||||
|
||||
impl EncryptedStorage {
|
||||
pub fn new(storage_path: PathBuf, password: &str) -> Result<Self> {
|
||||
let digest = digest::digest(&digest::SHA256, password.as_bytes());
|
||||
let cipher = Aes256Gcm::new_from_slice(digest.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("Invalid key length for AES-256"))?;
|
||||
|
||||
if let Some(parent) = storage_path.parent() {
|
||||
fs::create_dir_all(parent).context("Failed to ensure storage directory exists")?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
cipher,
|
||||
storage_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn store<T: Serialize>(&self, data: &T) -> Result<()> {
|
||||
let json = serde_json::to_vec(data).context("Failed to serialize data")?;
|
||||
|
||||
let nonce = generate_nonce()?;
|
||||
let nonce_ref = Nonce::from_slice(&nonce);
|
||||
|
||||
let ciphertext = self
|
||||
.cipher
|
||||
.encrypt(nonce_ref, json.as_ref())
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
|
||||
let encrypted_data = EncryptedData { nonce, ciphertext };
|
||||
let encrypted_json = serde_json::to_vec(&encrypted_data)?;
|
||||
|
||||
fs::write(&self.storage_path, encrypted_json).context("Failed to write encrypted data")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load<T: for<'de> Deserialize<'de>>(&self) -> Result<T> {
|
||||
let encrypted_json =
|
||||
fs::read(&self.storage_path).context("Failed to read encrypted data")?;
|
||||
|
||||
let encrypted_data: EncryptedData =
|
||||
serde_json::from_slice(&encrypted_json).context("Failed to parse encrypted data")?;
|
||||
|
||||
let nonce_ref = Nonce::from_slice(&encrypted_data.nonce);
|
||||
let plaintext = self
|
||||
.cipher
|
||||
.decrypt(nonce_ref, encrypted_data.ciphertext.as_ref())
|
||||
.map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))?;
|
||||
|
||||
let data: T =
|
||||
serde_json::from_slice(&plaintext).context("Failed to deserialize decrypted data")?;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub fn exists(&self) -> bool {
|
||||
self.storage_path.exists()
|
||||
}
|
||||
|
||||
pub fn delete(&self) -> Result<()> {
|
||||
if self.exists() {
|
||||
fs::remove_file(&self.storage_path).context("Failed to delete encrypted storage")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_password(&self) -> Result<()> {
|
||||
if !self.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let encrypted_json =
|
||||
fs::read(&self.storage_path).context("Failed to read encrypted data")?;
|
||||
|
||||
if encrypted_json.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let encrypted_data: EncryptedData =
|
||||
serde_json::from_slice(&encrypted_json).context("Failed to parse encrypted data")?;
|
||||
|
||||
let nonce_ref = Nonce::from_slice(&encrypted_data.nonce);
|
||||
self.cipher
|
||||
.decrypt(nonce_ref, encrypted_data.ciphertext.as_ref())
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt_password(prompt: &str) -> Result<String> {
|
||||
let password = rpassword::prompt_password(prompt)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read password: {e}"))?;
|
||||
if password.is_empty() {
|
||||
bail!("Password cannot be empty");
|
||||
}
|
||||
Ok(password)
|
||||
}
|
||||
|
||||
pub fn prompt_new_password() -> Result<String> {
|
||||
loop {
|
||||
let first = prompt_password("Enter new master password: ")?;
|
||||
let confirm = prompt_password("Confirm master password: ")?;
|
||||
if first == confirm {
|
||||
return Ok(first);
|
||||
}
|
||||
println!("Passwords did not match. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unlock_with_password(storage_path: PathBuf, password: &str) -> Result<VaultHandle> {
|
||||
let storage = EncryptedStorage::new(storage_path, password)?;
|
||||
let data = load_or_initialize_vault(&storage)?;
|
||||
Ok(VaultHandle { storage, data })
|
||||
}
|
||||
|
||||
pub fn unlock_interactive(storage_path: PathBuf) -> Result<VaultHandle> {
|
||||
if storage_path.exists() {
|
||||
for attempt in 0..3 {
|
||||
let password = prompt_password("Enter master password: ")?;
|
||||
match unlock_with_password(storage_path.clone(), &password) {
|
||||
Ok(handle) => return Ok(handle),
|
||||
Err(err) => {
|
||||
println!("Failed to unlock vault: {err}");
|
||||
if attempt == 2 {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("Failed to unlock encrypted storage after multiple attempts");
|
||||
} else {
|
||||
println!(
|
||||
"No encrypted storage found at {}. Initializing a new vault.",
|
||||
storage_path.display()
|
||||
);
|
||||
let password = prompt_new_password()?;
|
||||
let storage = EncryptedStorage::new(storage_path, &password)?;
|
||||
let data = VaultData {
|
||||
master_key: generate_master_key()?,
|
||||
..Default::default()
|
||||
};
|
||||
storage.store(&data)?;
|
||||
Ok(VaultHandle { storage, data })
|
||||
}
|
||||
}
|
||||
|
||||
fn load_or_initialize_vault(storage: &EncryptedStorage) -> Result<VaultData> {
|
||||
match storage.load::<VaultData>() {
|
||||
Ok(data) => {
|
||||
if data.master_key.len() != 32 {
|
||||
bail!(
|
||||
"Corrupted vault: master key has invalid length ({}). \
|
||||
Expected 32 bytes for AES-256. Vault cannot be recovered.",
|
||||
data.master_key.len()
|
||||
);
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
Err(err) => {
|
||||
if storage.exists() {
|
||||
return Err(err);
|
||||
}
|
||||
let data = VaultData {
|
||||
master_key: generate_master_key()?,
|
||||
..Default::default()
|
||||
};
|
||||
storage.store(&data)?;
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_master_key() -> Result<Vec<u8>> {
|
||||
let mut key = vec![0u8; 32];
|
||||
SystemRandom::new()
|
||||
.fill(&mut key)
|
||||
.map_err(|_| anyhow::anyhow!("Failed to generate master key"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn generate_nonce() -> Result<[u8; 12]> {
|
||||
let mut nonce = [0u8; 12];
|
||||
let rng = SystemRandom::new();
|
||||
rng.fill(&mut nonce)
|
||||
.map_err(|_| anyhow::anyhow!("Failed to generate nonce"))?;
|
||||
Ok(nonce)
|
||||
}
|
||||
@@ -4,25 +4,42 @@
|
||||
//! LLM providers, routers, and MCP (Model Context Protocol) adapters.
|
||||
|
||||
pub mod config;
|
||||
pub mod consent;
|
||||
pub mod conversation;
|
||||
pub mod credentials;
|
||||
pub mod encryption;
|
||||
pub mod formatting;
|
||||
pub mod input;
|
||||
pub mod mcp;
|
||||
pub mod model;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
pub mod sandbox;
|
||||
pub mod session;
|
||||
pub mod storage;
|
||||
pub mod theme;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
pub mod ui;
|
||||
pub mod validation;
|
||||
pub mod wrap_cursor;
|
||||
|
||||
pub use config::*;
|
||||
pub use consent::*;
|
||||
pub use conversation::*;
|
||||
pub use credentials::*;
|
||||
pub use encryption::*;
|
||||
pub use formatting::*;
|
||||
pub use input::*;
|
||||
pub use mcp::*;
|
||||
pub use model::*;
|
||||
pub use provider::*;
|
||||
pub use router::*;
|
||||
pub use sandbox::*;
|
||||
pub use session::*;
|
||||
pub use theme::*;
|
||||
pub use tools::*;
|
||||
pub use validation::*;
|
||||
|
||||
/// Result type used throughout the OWLEN ecosystem
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -54,6 +71,9 @@ pub enum Error {
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
82
crates/owlen-core/src/mcp/mod.rs
Normal file
82
crates/owlen-core/src/mcp/mod.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::validation::SchemaValidator;
|
||||
use crate::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Descriptor for a tool exposed over MCP
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolDescriptor {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: Value,
|
||||
pub requires_network: bool,
|
||||
pub requires_filesystem: Vec<String>,
|
||||
}
|
||||
|
||||
/// Invocation payload for a tool call
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolCall {
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
/// Result returned by a tool invocation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolResponse {
|
||||
pub name: String,
|
||||
pub success: bool,
|
||||
pub output: Value,
|
||||
pub metadata: HashMap<String, String>,
|
||||
pub duration_ms: u128,
|
||||
}
|
||||
|
||||
/// Thin MCP server facade over the tool registry
|
||||
pub struct McpServer {
|
||||
registry: Arc<ToolRegistry>,
|
||||
validator: Arc<SchemaValidator>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
pub fn new(registry: Arc<ToolRegistry>, validator: Arc<SchemaValidator>) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
validator,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate the registered tools as MCP descriptors
|
||||
pub fn list_tools(&self) -> Vec<McpToolDescriptor> {
|
||||
self.registry
|
||||
.all()
|
||||
.into_iter()
|
||||
.map(|tool| McpToolDescriptor {
|
||||
name: tool.name().to_string(),
|
||||
description: tool.description().to_string(),
|
||||
input_schema: tool.schema(),
|
||||
requires_network: tool.requires_network(),
|
||||
requires_filesystem: tool.requires_filesystem(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Execute a tool call after validating inputs against the registered schema
|
||||
pub async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||
self.validator.validate(&call.name, &call.arguments)?;
|
||||
let result = self.registry.execute(&call.name, call.arguments).await?;
|
||||
Ok(McpToolResponse {
|
||||
name: call.name,
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
metadata: result.metadata,
|
||||
duration_ms: duration_to_millis(result.duration),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_to_millis(duration: Duration) -> u128 {
|
||||
duration.as_secs() as u128 * 1_000 + u128::from(duration.subsec_millis())
|
||||
}
|
||||
@@ -9,6 +9,72 @@ use std::sync::Arc;
|
||||
pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
||||
|
||||
/// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.)
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use std::pin::Pin;
|
||||
/// use std::sync::Arc;
|
||||
/// use futures::Stream;
|
||||
/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream};
|
||||
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message};
|
||||
/// use owlen_core::Result;
|
||||
///
|
||||
/// // 1. Create a mock provider
|
||||
/// struct MockProvider;
|
||||
///
|
||||
/// #[async_trait::async_trait]
|
||||
/// impl Provider for MockProvider {
|
||||
/// fn name(&self) -> &str {
|
||||
/// "mock"
|
||||
/// }
|
||||
///
|
||||
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
||||
/// Ok(vec![ModelInfo {
|
||||
/// provider: "mock".to_string(),
|
||||
/// name: "mock-model".to_string(),
|
||||
/// ..Default::default()
|
||||
/// }])
|
||||
/// }
|
||||
///
|
||||
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||
/// let content = format!("Response to: {}", request.messages.last().unwrap().content);
|
||||
/// Ok(ChatResponse {
|
||||
/// model: request.model,
|
||||
/// message: Message { role: "assistant".to_string(), content, ..Default::default() },
|
||||
/// ..Default::default()
|
||||
/// })
|
||||
/// }
|
||||
///
|
||||
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> {
|
||||
/// unimplemented!();
|
||||
/// }
|
||||
///
|
||||
/// async fn health_check(&self) -> Result<()> {
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // 2. Use the provider with a registry
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let mut registry = ProviderRegistry::new();
|
||||
/// registry.register(MockProvider);
|
||||
///
|
||||
/// let provider = registry.get("mock").unwrap();
|
||||
/// let models = provider.list_models().await.unwrap();
|
||||
/// assert_eq!(models[0].name, "mock-model");
|
||||
///
|
||||
/// let request = ChatRequest {
|
||||
/// model: "mock-model".to_string(),
|
||||
/// messages: vec![Message { role: "user".to_string(), content: "Hello".to_string(), ..Default::default() }],
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
///
|
||||
/// let response = provider.chat(request).await.unwrap();
|
||||
/// assert_eq!(response.message.content, "Response to: Hello");
|
||||
/// }
|
||||
/// ```
|
||||
#[async_trait::async_trait]
|
||||
pub trait Provider: Send + Sync {
|
||||
/// Get the name of this provider
|
||||
@@ -87,9 +153,8 @@ impl ProviderRegistry {
|
||||
for provider in self.providers.values() {
|
||||
match provider.list_models().await {
|
||||
Ok(mut models) => all_models.append(&mut models),
|
||||
Err(e) => {
|
||||
// Log error but continue with other providers
|
||||
eprintln!("Failed to get models from {}: {}", provider.name(), e);
|
||||
Err(_) => {
|
||||
// Continue with other providers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
212
crates/owlen-core/src/sandbox.rs
Normal file
212
crates/owlen-core/src/sandbox.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Configuration options for sandboxed process execution.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SandboxConfig {
|
||||
pub allow_network: bool,
|
||||
pub allow_paths: Vec<PathBuf>,
|
||||
pub readonly_paths: Vec<PathBuf>,
|
||||
pub timeout_seconds: u64,
|
||||
pub max_memory_mb: u64,
|
||||
}
|
||||
|
||||
impl Default for SandboxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allow_network: false,
|
||||
allow_paths: Vec::new(),
|
||||
readonly_paths: Vec::new(),
|
||||
timeout_seconds: 30,
|
||||
max_memory_mb: 512,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper around a bubblewrap sandbox instance.
|
||||
///
|
||||
/// Memory limits are enforced via:
|
||||
/// - bwrap's --rlimit-as (version >= 0.12.0)
|
||||
/// - prlimit wrapper (fallback for older bwrap versions)
|
||||
/// - timeout mechanism (always enforced as last resort)
|
||||
pub struct SandboxedProcess {
|
||||
temp_dir: TempDir,
|
||||
config: SandboxConfig,
|
||||
}
|
||||
|
||||
impl SandboxedProcess {
|
||||
pub fn new(config: SandboxConfig) -> Result<Self> {
|
||||
let temp_dir = TempDir::new().context("Failed to create temp directory")?;
|
||||
|
||||
which::which("bwrap")
|
||||
.context("bubblewrap not found. Install with: sudo apt install bubblewrap")?;
|
||||
|
||||
Ok(Self { temp_dir, config })
|
||||
}
|
||||
|
||||
pub fn execute(&self, command: &str, args: &[&str]) -> Result<SandboxResult> {
|
||||
let supports_rlimit = self.supports_rlimit_as();
|
||||
let use_prlimit = !supports_rlimit && which::which("prlimit").is_ok();
|
||||
|
||||
let mut cmd = if use_prlimit {
|
||||
// Use prlimit wrapper for older bwrap versions
|
||||
let mut prlimit_cmd = Command::new("prlimit");
|
||||
let memory_limit_bytes = self
|
||||
.config
|
||||
.max_memory_mb
|
||||
.saturating_mul(1024)
|
||||
.saturating_mul(1024);
|
||||
prlimit_cmd.arg(format!("--as={}", memory_limit_bytes));
|
||||
prlimit_cmd.arg("bwrap");
|
||||
prlimit_cmd
|
||||
} else {
|
||||
Command::new("bwrap")
|
||||
};
|
||||
|
||||
cmd.args(["--unshare-all", "--die-with-parent", "--new-session"]);
|
||||
|
||||
if self.config.allow_network {
|
||||
cmd.arg("--share-net");
|
||||
} else {
|
||||
cmd.arg("--unshare-net");
|
||||
}
|
||||
|
||||
cmd.args(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]);
|
||||
|
||||
// Bind essential system paths readonly for executables and libraries
|
||||
let system_paths = ["/usr", "/bin", "/lib", "/lib64", "/etc"];
|
||||
for sys_path in &system_paths {
|
||||
let path = std::path::Path::new(sys_path);
|
||||
if path.exists() {
|
||||
cmd.arg("--ro-bind").arg(sys_path).arg(sys_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Bind /run for DNS resolution (resolv.conf may be a symlink to /run/systemd/resolve/*)
|
||||
if std::path::Path::new("/run").exists() {
|
||||
cmd.arg("--ro-bind").arg("/run").arg("/run");
|
||||
}
|
||||
|
||||
for path in &self.config.allow_paths {
|
||||
let path_host = path.to_string_lossy().into_owned();
|
||||
let path_guest = path_host.clone();
|
||||
cmd.arg("--bind").arg(&path_host).arg(&path_guest);
|
||||
}
|
||||
|
||||
for path in &self.config.readonly_paths {
|
||||
let path_host = path.to_string_lossy().into_owned();
|
||||
let path_guest = path_host.clone();
|
||||
cmd.arg("--ro-bind").arg(&path_host).arg(&path_guest);
|
||||
}
|
||||
|
||||
let work_dir = self.temp_dir.path().to_string_lossy().into_owned();
|
||||
cmd.arg("--bind").arg(&work_dir).arg("/work");
|
||||
cmd.arg("--chdir").arg("/work");
|
||||
|
||||
// Add memory limits via bwrap's --rlimit-as if supported (version >= 0.12.0)
|
||||
// If not supported, we use prlimit wrapper (set earlier)
|
||||
if supports_rlimit && !use_prlimit {
|
||||
let memory_limit_bytes = self
|
||||
.config
|
||||
.max_memory_mb
|
||||
.saturating_mul(1024)
|
||||
.saturating_mul(1024);
|
||||
let memory_soft = memory_limit_bytes.to_string();
|
||||
let memory_hard = memory_limit_bytes.to_string();
|
||||
cmd.arg("--rlimit-as").arg(&memory_soft).arg(&memory_hard);
|
||||
}
|
||||
|
||||
cmd.arg(command);
|
||||
cmd.args(args);
|
||||
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_secs(self.config.timeout_seconds);
|
||||
|
||||
// Spawn the process instead of waiting immediately
|
||||
let mut child = cmd
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to spawn sandboxed command")?;
|
||||
|
||||
let mut was_timeout = false;
|
||||
|
||||
// Wait for the child with timeout
|
||||
let output = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_status)) => {
|
||||
// Process exited
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.context("Failed to collect process output")?;
|
||||
break output;
|
||||
}
|
||||
Ok(None) => {
|
||||
// Process still running, check timeout
|
||||
if start.elapsed() >= timeout {
|
||||
// Timeout exceeded, kill the process
|
||||
was_timeout = true;
|
||||
child.kill().context("Failed to kill timed-out process")?;
|
||||
// Wait for the killed process to exit
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.context("Failed to collect output from killed process")?;
|
||||
break output;
|
||||
}
|
||||
// Sleep briefly before checking again
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => {
|
||||
bail!("Failed to check process status: {}", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let duration = start.elapsed();
|
||||
|
||||
Ok(SandboxResult {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
exit_code: output.status.code().unwrap_or(-1),
|
||||
duration,
|
||||
was_timeout,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if bubblewrap supports --rlimit-as option (version >= 0.12.0)
|
||||
fn supports_rlimit_as(&self) -> bool {
|
||||
// Try to get bwrap version
|
||||
let output = Command::new("bwrap").arg("--version").output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
let version_str = String::from_utf8_lossy(&output.stdout);
|
||||
// Parse version like "bubblewrap 0.11.0" or "0.11.0"
|
||||
if let Some(version_part) = version_str.split_whitespace().last() {
|
||||
if let Some((major, rest)) = version_part.split_once('.') {
|
||||
if let Some((minor, _patch)) = rest.split_once('.') {
|
||||
if let (Ok(maj), Ok(min)) = (major.parse::<u32>(), minor.parse::<u32>()) {
|
||||
// --rlimit-as was added in 0.12.0
|
||||
return maj > 0 || (maj == 0 && min >= 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't determine the version, assume it doesn't support it (safer default)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SandboxResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: i32,
|
||||
pub duration: Duration,
|
||||
pub was_timeout: bool,
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
use crate::config::Config;
|
||||
use crate::consent::ConsentManager;
|
||||
use crate::conversation::ConversationManager;
|
||||
use crate::credentials::CredentialManager;
|
||||
use crate::encryption::{self, VaultHandle};
|
||||
use crate::formatting::MessageFormatter;
|
||||
use crate::input::InputBuffer;
|
||||
use crate::model::ModelManager;
|
||||
use crate::provider::{ChatStream, Provider};
|
||||
use crate::types::{ChatParameters, ChatRequest, ChatResponse, Conversation, ModelInfo};
|
||||
use crate::Result;
|
||||
use std::sync::Arc;
|
||||
use crate::storage::{SessionMeta, StorageManager};
|
||||
use crate::tools::{
|
||||
code_exec::CodeExecTool, registry::ToolRegistry, web_search::WebSearchTool,
|
||||
web_search_detailed::WebSearchDetailedTool, Tool,
|
||||
};
|
||||
use crate::types::{
|
||||
ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, ToolCall,
|
||||
};
|
||||
use crate::validation::{get_builtin_schemas, SchemaValidator};
|
||||
use crate::{Error, Result};
|
||||
use log::warn;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Outcome of submitting a chat request
|
||||
@@ -20,7 +34,64 @@ pub enum SessionOutcome {
|
||||
},
|
||||
}
|
||||
|
||||
/// High-level controller encapsulating session state and provider interactions
|
||||
/// High-level controller encapsulating session state and provider interactions.
|
||||
///
|
||||
/// This is the main entry point for managing conversations and interacting with LLM providers.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use std::sync::Arc;
|
||||
/// use owlen_core::config::Config;
|
||||
/// use owlen_core::provider::{Provider, ChatStream};
|
||||
/// use owlen_core::session::{SessionController, SessionOutcome};
|
||||
/// use owlen_core::storage::StorageManager;
|
||||
/// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo};
|
||||
/// use owlen_core::Result;
|
||||
///
|
||||
/// // Mock provider for the example
|
||||
/// struct MockProvider;
|
||||
/// #[async_trait::async_trait]
|
||||
/// impl Provider for MockProvider {
|
||||
/// fn name(&self) -> &str { "mock" }
|
||||
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> { Ok(vec![]) }
|
||||
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||
/// Ok(ChatResponse {
|
||||
/// model: request.model,
|
||||
/// message: Message::assistant("Hello back!".to_string()),
|
||||
/// ..Default::default()
|
||||
/// })
|
||||
/// }
|
||||
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> { unimplemented!() }
|
||||
/// async fn health_check(&self) -> Result<()> { Ok(()) }
|
||||
/// }
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let provider = Arc::new(MockProvider);
|
||||
/// let config = Config::default();
|
||||
/// let storage = Arc::new(StorageManager::new().await.unwrap());
|
||||
/// let enable_code_tools = false; // Set to true for code client
|
||||
/// let mut session_controller = SessionController::new(provider, config, storage, enable_code_tools).unwrap();
|
||||
///
|
||||
/// // Send a message
|
||||
/// let outcome = session_controller.send_message(
|
||||
/// "Hello".to_string(),
|
||||
/// ChatParameters { stream: false, ..Default::default() }
|
||||
/// ).await.unwrap();
|
||||
///
|
||||
/// // Check the response
|
||||
/// if let SessionOutcome::Complete(response) = outcome {
|
||||
/// assert_eq!(response.message.content, "Hello back!");
|
||||
/// }
|
||||
///
|
||||
/// // The conversation now contains both messages
|
||||
/// let messages = session_controller.conversation().messages.clone();
|
||||
/// assert_eq!(messages.len(), 2);
|
||||
/// assert_eq!(messages[0].content, "Hello");
|
||||
/// assert_eq!(messages[1].content, "Hello back!");
|
||||
/// }
|
||||
/// ```
|
||||
pub struct SessionController {
|
||||
provider: Arc<dyn Provider>,
|
||||
conversation: ConversationManager,
|
||||
@@ -28,17 +99,69 @@ pub struct SessionController {
|
||||
input_buffer: InputBuffer,
|
||||
formatter: MessageFormatter,
|
||||
config: Config,
|
||||
consent_manager: Arc<Mutex<ConsentManager>>,
|
||||
tool_registry: Arc<ToolRegistry>,
|
||||
schema_validator: Arc<SchemaValidator>,
|
||||
storage: Arc<StorageManager>,
|
||||
vault: Option<Arc<Mutex<VaultHandle>>>,
|
||||
master_key: Option<Arc<Vec<u8>>>,
|
||||
credential_manager: Option<Arc<CredentialManager>>,
|
||||
enable_code_tools: bool, // Whether to enable code execution tools (code client only)
|
||||
}
|
||||
|
||||
impl SessionController {
|
||||
/// Create a new controller with the given provider and configuration
|
||||
pub fn new(provider: Arc<dyn Provider>, config: Config) -> Self {
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - The LLM provider to use
|
||||
/// * `config` - Application configuration
|
||||
/// * `storage` - Storage manager for persistence
|
||||
/// * `enable_code_tools` - Whether to enable code execution tools (should only be true for code client)
|
||||
pub fn new(
|
||||
provider: Arc<dyn Provider>,
|
||||
config: Config,
|
||||
storage: Arc<StorageManager>,
|
||||
enable_code_tools: bool,
|
||||
) -> Result<Self> {
|
||||
let model = config
|
||||
.general
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "ollama/default".to_string());
|
||||
|
||||
let mut vault_handle: Option<Arc<Mutex<VaultHandle>>> = None;
|
||||
let mut master_key: Option<Arc<Vec<u8>>> = None;
|
||||
let mut credential_manager: Option<Arc<CredentialManager>> = None;
|
||||
|
||||
if config.privacy.encrypt_local_data {
|
||||
let base_dir = storage
|
||||
.database_path()
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.or_else(dirs::data_local_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let secure_path = base_dir.join("encrypted_data.json");
|
||||
|
||||
let handle = match env::var("OWLEN_MASTER_PASSWORD") {
|
||||
Ok(password) if !password.is_empty() => {
|
||||
encryption::unlock_with_password(secure_path, &password)?
|
||||
}
|
||||
_ => encryption::unlock_interactive(secure_path)?,
|
||||
};
|
||||
|
||||
let master = Arc::new(handle.data.master_key.clone());
|
||||
master_key = Some(master.clone());
|
||||
vault_handle = Some(Arc::new(Mutex::new(handle)));
|
||||
credential_manager = Some(Arc::new(CredentialManager::new(storage.clone(), master)));
|
||||
}
|
||||
|
||||
// Load consent manager from vault if available, otherwise create new
|
||||
let consent_manager = if let Some(ref vault) = vault_handle {
|
||||
Arc::new(Mutex::new(ConsentManager::from_vault(vault)))
|
||||
} else {
|
||||
Arc::new(Mutex::new(ConsentManager::new()))
|
||||
};
|
||||
|
||||
let conversation =
|
||||
ConversationManager::with_history_capacity(model, config.storage.max_saved_sessions);
|
||||
let formatter =
|
||||
@@ -52,14 +175,26 @@ impl SessionController {
|
||||
|
||||
let model_manager = ModelManager::new(config.general.model_cache_ttl());
|
||||
|
||||
Self {
|
||||
let mut controller = Self {
|
||||
provider,
|
||||
conversation,
|
||||
model_manager,
|
||||
input_buffer,
|
||||
formatter,
|
||||
config,
|
||||
}
|
||||
consent_manager,
|
||||
tool_registry: Arc::new(ToolRegistry::new()),
|
||||
schema_validator: Arc::new(SchemaValidator::new()),
|
||||
storage,
|
||||
vault: vault_handle,
|
||||
master_key,
|
||||
credential_manager,
|
||||
enable_code_tools,
|
||||
};
|
||||
|
||||
controller.rebuild_tools()?;
|
||||
|
||||
Ok(controller)
|
||||
}
|
||||
|
||||
/// Access the active conversation
|
||||
@@ -102,6 +237,260 @@ impl SessionController {
|
||||
&mut self.config
|
||||
}
|
||||
|
||||
/// Grant consent programmatically for a tool (for TUI consent dialog)
|
||||
pub fn grant_consent(&self, tool_name: &str, data_types: Vec<String>, endpoints: Vec<String>) {
|
||||
let mut consent = self
|
||||
.consent_manager
|
||||
.lock()
|
||||
.expect("Consent manager mutex poisoned");
|
||||
consent.grant_consent(tool_name, data_types, endpoints);
|
||||
|
||||
// Persist to vault if available
|
||||
if let Some(vault) = &self.vault {
|
||||
if let Err(e) = consent.persist_to_vault(vault) {
|
||||
eprintln!("Warning: Failed to persist consent to vault: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if consent is needed for tool calls (non-blocking check)
|
||||
/// Returns a list of (tool_name, data_types, endpoints) tuples for tools that need consent
|
||||
pub fn check_tools_consent_needed(
|
||||
&self,
|
||||
tool_calls: &[ToolCall],
|
||||
) -> Vec<(String, Vec<String>, Vec<String>)> {
|
||||
let consent = self
|
||||
.consent_manager
|
||||
.lock()
|
||||
.expect("Consent manager mutex poisoned");
|
||||
let mut needs_consent = Vec::new();
|
||||
let mut seen_tools = std::collections::HashSet::new();
|
||||
|
||||
for tool_call in tool_calls {
|
||||
// Skip if we already checked this tool
|
||||
if seen_tools.contains(&tool_call.name) {
|
||||
continue;
|
||||
}
|
||||
seen_tools.insert(tool_call.name.clone());
|
||||
|
||||
// Get tool metadata (data types and endpoints) based on tool name
|
||||
let (data_types, endpoints) = match tool_call.name.as_str() {
|
||||
"web_search" | "web_search_detailed" => (
|
||||
vec!["search query".to_string()],
|
||||
vec!["duckduckgo.com".to_string()],
|
||||
),
|
||||
"code_exec" => (
|
||||
vec!["code to execute".to_string()],
|
||||
vec!["local sandbox".to_string()],
|
||||
),
|
||||
_ => (vec![], vec![]),
|
||||
};
|
||||
|
||||
if let Some((tool_name, dt, ep)) =
|
||||
consent.check_if_consent_needed(&tool_call.name, data_types, endpoints)
|
||||
{
|
||||
needs_consent.push((tool_name, dt, ep));
|
||||
}
|
||||
}
|
||||
|
||||
needs_consent
|
||||
}
|
||||
|
||||
/// Persist the active conversation to storage
|
||||
pub async fn save_active_session(
|
||||
&self,
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<Uuid> {
|
||||
self.conversation
|
||||
.save_active_with_description(&self.storage, name, description)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persist the active conversation without description override
|
||||
pub async fn save_active_session_simple(&self, name: Option<String>) -> Result<Uuid> {
|
||||
self.conversation.save_active(&self.storage, name).await
|
||||
}
|
||||
|
||||
/// Load a saved conversation by ID and make it active
|
||||
pub async fn load_saved_session(&mut self, id: Uuid) -> Result<()> {
|
||||
self.conversation.load_saved(&self.storage, id).await
|
||||
}
|
||||
|
||||
/// Retrieve session metadata from storage
|
||||
pub async fn list_saved_sessions(&self) -> Result<Vec<SessionMeta>> {
|
||||
ConversationManager::list_saved_sessions(&self.storage).await
|
||||
}
|
||||
|
||||
pub async fn delete_session(&self, id: Uuid) -> Result<()> {
|
||||
self.storage.delete_session(id).await
|
||||
}
|
||||
|
||||
pub async fn clear_secure_data(&self) -> Result<()> {
|
||||
self.storage.clear_secure_items().await?;
|
||||
if let Some(vault) = &self.vault {
|
||||
let mut guard = vault.lock().expect("Vault mutex poisoned");
|
||||
guard.data.settings.clear();
|
||||
guard.persist()?;
|
||||
}
|
||||
// Also clear consent records
|
||||
{
|
||||
let mut consent = self
|
||||
.consent_manager
|
||||
.lock()
|
||||
.expect("Consent manager mutex poisoned");
|
||||
consent.clear_all_consent();
|
||||
}
|
||||
self.persist_consent()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist current consent state to vault (if encryption is enabled)
|
||||
pub fn persist_consent(&self) -> Result<()> {
|
||||
if let Some(vault) = &self.vault {
|
||||
let consent = self
|
||||
.consent_manager
|
||||
.lock()
|
||||
.expect("Consent manager mutex poisoned");
|
||||
consent.persist_to_vault(vault)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_tool_enabled(&mut self, tool: &str, enabled: bool) -> Result<()> {
|
||||
match tool {
|
||||
"web_search" => {
|
||||
self.config.tools.web_search.enabled = enabled;
|
||||
self.config.privacy.enable_remote_search = enabled;
|
||||
}
|
||||
"code_exec" => {
|
||||
self.config.tools.code_exec.enabled = enabled;
|
||||
}
|
||||
other => {
|
||||
return Err(Error::InvalidInput(format!("Unknown tool: {other}")));
|
||||
}
|
||||
}
|
||||
|
||||
self.rebuild_tools()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Access the consent manager shared across tools
|
||||
pub fn consent_manager(&self) -> Arc<Mutex<ConsentManager>> {
|
||||
self.consent_manager.clone()
|
||||
}
|
||||
|
||||
/// Access the tool registry for executing registered tools
|
||||
pub fn tool_registry(&self) -> Arc<ToolRegistry> {
|
||||
Arc::clone(&self.tool_registry)
|
||||
}
|
||||
|
||||
/// Access the schema validator used for tool input validation
|
||||
pub fn schema_validator(&self) -> Arc<SchemaValidator> {
|
||||
Arc::clone(&self.schema_validator)
|
||||
}
|
||||
|
||||
/// Construct an MCP server facade for the active tool registry
|
||||
pub fn mcp_server(&self) -> crate::mcp::McpServer {
|
||||
crate::mcp::McpServer::new(self.tool_registry(), self.schema_validator())
|
||||
}
|
||||
|
||||
/// Access the underlying storage manager
|
||||
pub fn storage(&self) -> Arc<StorageManager> {
|
||||
Arc::clone(&self.storage)
|
||||
}
|
||||
|
||||
/// Retrieve the active master key if encryption is enabled
|
||||
pub fn master_key(&self) -> Option<Arc<Vec<u8>>> {
|
||||
self.master_key.as_ref().map(Arc::clone)
|
||||
}
|
||||
|
||||
/// Access the vault handle for managing secure settings
|
||||
pub fn vault(&self) -> Option<Arc<Mutex<VaultHandle>>> {
|
||||
self.vault.as_ref().map(Arc::clone)
|
||||
}
|
||||
|
||||
/// Access the credential manager if available
|
||||
pub fn credential_manager(&self) -> Option<Arc<CredentialManager>> {
|
||||
self.credential_manager.as_ref().map(Arc::clone)
|
||||
}
|
||||
|
||||
fn rebuild_tools(&mut self) -> Result<()> {
|
||||
let mut registry = ToolRegistry::new();
|
||||
let mut validator = SchemaValidator::new();
|
||||
|
||||
for (name, schema) in get_builtin_schemas() {
|
||||
if let Err(err) = validator.register_schema(&name, schema) {
|
||||
warn!("Failed to register built-in schema {name}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
if self
|
||||
.config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "web_search")
|
||||
&& self.config.tools.web_search.enabled
|
||||
&& self.config.privacy.enable_remote_search
|
||||
{
|
||||
let tool = WebSearchTool::new(
|
||||
self.consent_manager.clone(),
|
||||
self.credential_manager.clone(),
|
||||
self.vault.clone(),
|
||||
);
|
||||
let schema = tool.schema();
|
||||
if let Err(err) = validator.register_schema(tool.name(), schema) {
|
||||
warn!("Failed to register schema for {}: {err}", tool.name());
|
||||
}
|
||||
registry.register(tool);
|
||||
}
|
||||
|
||||
// Register web_search_detailed tool (provides snippets)
|
||||
if self
|
||||
.config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "web_search") // Same permission as web_search
|
||||
&& self.config.tools.web_search.enabled
|
||||
&& self.config.privacy.enable_remote_search
|
||||
{
|
||||
let tool = WebSearchDetailedTool::new(
|
||||
self.consent_manager.clone(),
|
||||
self.credential_manager.clone(),
|
||||
self.vault.clone(),
|
||||
);
|
||||
let schema = tool.schema();
|
||||
if let Err(err) = validator.register_schema(tool.name(), schema) {
|
||||
warn!("Failed to register schema for {}: {err}", tool.name());
|
||||
}
|
||||
registry.register(tool);
|
||||
}
|
||||
|
||||
// Code execution tool - only available in code client
|
||||
if self.enable_code_tools
|
||||
&& self
|
||||
.config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "code_exec")
|
||||
&& self.config.tools.code_exec.enabled
|
||||
{
|
||||
let tool = CodeExecTool::new(self.config.tools.code_exec.allowed_languages.clone());
|
||||
let schema = tool.schema();
|
||||
if let Err(err) = validator.register_schema(tool.name(), schema) {
|
||||
warn!("Failed to register schema for {}: {err}", tool.name());
|
||||
}
|
||||
registry.register(tool);
|
||||
}
|
||||
|
||||
self.tool_registry = Arc::new(registry);
|
||||
self.schema_validator = Arc::new(validator);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Currently selected model identifier
|
||||
pub fn selected_model(&self) -> &str {
|
||||
&self.conversation.active().model
|
||||
@@ -133,6 +522,13 @@ impl SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the active provider at runtime and invalidate cached model listings
|
||||
pub async fn switch_provider(&mut self, provider: Arc<dyn Provider>) -> Result<()> {
|
||||
self.provider = provider;
|
||||
self.model_manager.invalidate().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Submit a user message; optionally stream the response
|
||||
pub async fn send_message(
|
||||
&mut self,
|
||||
@@ -156,38 +552,104 @@ impl SessionController {
|
||||
let streaming = parameters.stream || self.config.general.enable_streaming;
|
||||
parameters.stream = streaming;
|
||||
|
||||
let request = ChatRequest {
|
||||
model: self.conversation.active().model.clone(),
|
||||
messages: self.conversation.active().messages.clone(),
|
||||
parameters,
|
||||
// Get available tools
|
||||
let tools = if !self.tool_registry.all().is_empty() {
|
||||
Some(
|
||||
self.tool_registry
|
||||
.all()
|
||||
.into_iter()
|
||||
.map(|tool| crate::mcp::McpToolDescriptor {
|
||||
name: tool.name().to_string(),
|
||||
description: tool.description().to_string(),
|
||||
input_schema: tool.schema(),
|
||||
requires_network: tool.requires_network(),
|
||||
requires_filesystem: tool.requires_filesystem(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if streaming {
|
||||
match self.provider.chat_stream(request).await {
|
||||
Ok(stream) => {
|
||||
let response_id = self.conversation.start_streaming_response();
|
||||
Ok(SessionOutcome::Streaming {
|
||||
response_id,
|
||||
stream,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
self.conversation
|
||||
.push_assistant_message(format!("Error starting stream: {}", err));
|
||||
Err(err)
|
||||
let mut request = ChatRequest {
|
||||
model: self.conversation.active().model.clone(),
|
||||
messages: self.conversation.active().messages.clone(),
|
||||
parameters: parameters.clone(),
|
||||
tools: tools.clone(),
|
||||
};
|
||||
|
||||
// Tool execution loop (non-streaming only for now)
|
||||
if !streaming {
|
||||
const MAX_TOOL_ITERATIONS: usize = 5;
|
||||
for _iteration in 0..MAX_TOOL_ITERATIONS {
|
||||
match self.provider.chat(request.clone()).await {
|
||||
Ok(response) => {
|
||||
// Check if the response has tool calls
|
||||
if response.message.has_tool_calls() {
|
||||
// Add assistant's tool call message to conversation
|
||||
self.conversation.push_message(response.message.clone());
|
||||
|
||||
// Execute each tool call
|
||||
if let Some(tool_calls) = &response.message.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
let tool_result = self
|
||||
.tool_registry
|
||||
.execute(&tool_call.name, tool_call.arguments.clone())
|
||||
.await;
|
||||
|
||||
let tool_response_content = match tool_result {
|
||||
Ok(result) => serde_json::to_string_pretty(&result.output)
|
||||
.unwrap_or_else(|_| {
|
||||
"Tool execution succeeded".to_string()
|
||||
}),
|
||||
Err(e) => format!("Tool execution failed: {}", e),
|
||||
};
|
||||
|
||||
// Add tool response to conversation
|
||||
let tool_msg =
|
||||
Message::tool(tool_call.id.clone(), tool_response_content);
|
||||
self.conversation.push_message(tool_msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Update request with new messages for next iteration
|
||||
request.messages = self.conversation.active().messages.clone();
|
||||
continue;
|
||||
} else {
|
||||
// No more tool calls, return final response
|
||||
self.conversation.push_message(response.message.clone());
|
||||
return Ok(SessionOutcome::Complete(response));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.conversation
|
||||
.push_assistant_message(format!("Error: {}", err));
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match self.provider.chat(request).await {
|
||||
Ok(response) => {
|
||||
self.conversation.push_message(response.message.clone());
|
||||
Ok(SessionOutcome::Complete(response))
|
||||
}
|
||||
Err(err) => {
|
||||
self.conversation
|
||||
.push_assistant_message(format!("Error: {}", err));
|
||||
Err(err)
|
||||
}
|
||||
|
||||
// Max iterations reached
|
||||
self.conversation
|
||||
.push_assistant_message("Maximum tool execution iterations reached".to_string());
|
||||
return Err(crate::Error::Provider(anyhow::anyhow!(
|
||||
"Maximum tool execution iterations reached"
|
||||
)));
|
||||
}
|
||||
|
||||
// Streaming mode with tool support
|
||||
match self.provider.chat_stream(request).await {
|
||||
Ok(stream) => {
|
||||
let response_id = self.conversation.start_streaming_response();
|
||||
Ok(SessionOutcome::Streaming {
|
||||
response_id,
|
||||
stream,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
self.conversation
|
||||
.push_assistant_message(format!("Error starting stream: {}", err));
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,10 +662,64 @@ impl SessionController {
|
||||
|
||||
/// Apply streaming chunk to the conversation
|
||||
pub fn apply_stream_chunk(&mut self, message_id: Uuid, chunk: &ChatResponse) -> Result<()> {
|
||||
// Check if this chunk contains tool calls
|
||||
if chunk.message.has_tool_calls() {
|
||||
// This is a tool call chunk - store the tool calls on the message
|
||||
self.conversation.set_tool_calls_on_message(
|
||||
message_id,
|
||||
chunk.message.tool_calls.clone().unwrap_or_default(),
|
||||
)?;
|
||||
}
|
||||
|
||||
self.conversation
|
||||
.append_stream_chunk(message_id, &chunk.message.content, chunk.is_final)
|
||||
}
|
||||
|
||||
/// Check if a streaming message has complete tool calls that need execution
|
||||
pub fn check_streaming_tool_calls(&self, message_id: Uuid) -> Option<Vec<ToolCall>> {
|
||||
self.conversation
|
||||
.active()
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.id == message_id)
|
||||
.and_then(|m| m.tool_calls.clone())
|
||||
.filter(|calls| !calls.is_empty())
|
||||
}
|
||||
|
||||
/// Execute tools for a streaming response and continue conversation
|
||||
pub async fn execute_streaming_tools(
|
||||
&mut self,
|
||||
_message_id: Uuid,
|
||||
tool_calls: Vec<ToolCall>,
|
||||
) -> Result<SessionOutcome> {
|
||||
// Execute each tool call
|
||||
for tool_call in &tool_calls {
|
||||
let tool_result = self
|
||||
.tool_registry
|
||||
.execute(&tool_call.name, tool_call.arguments.clone())
|
||||
.await;
|
||||
|
||||
let tool_response_content = match tool_result {
|
||||
Ok(result) => serde_json::to_string_pretty(&result.output)
|
||||
.unwrap_or_else(|_| "Tool execution succeeded".to_string()),
|
||||
Err(e) => format!("Tool execution failed: {}", e),
|
||||
};
|
||||
|
||||
// Add tool response to conversation
|
||||
let tool_msg = Message::tool(tool_call.id.clone(), tool_response_content);
|
||||
self.conversation.push_message(tool_msg);
|
||||
}
|
||||
|
||||
// Continue the conversation with tool results
|
||||
let parameters = ChatParameters {
|
||||
stream: self.config.general.enable_streaming,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
self.send_request_with_current_conversation(parameters)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Access conversation history
|
||||
pub fn history(&self) -> Vec<Conversation> {
|
||||
self.conversation.history().cloned().collect()
|
||||
@@ -218,4 +734,114 @@ impl SessionController {
|
||||
pub fn clear(&mut self) {
|
||||
self.conversation.clear();
|
||||
}
|
||||
|
||||
/// Generate a short AI description for the current conversation
|
||||
pub async fn generate_conversation_description(&self) -> Result<String> {
|
||||
let conv = self.conversation.active();
|
||||
|
||||
// If conversation is empty or very short, return a simple description
|
||||
if conv.messages.is_empty() {
|
||||
return Ok("Empty conversation".to_string());
|
||||
}
|
||||
|
||||
if conv.messages.len() == 1 {
|
||||
let first_msg = &conv.messages[0];
|
||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||
return Ok(format!(
|
||||
"{}{}",
|
||||
preview,
|
||||
if first_msg.content.len() > 50 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
// Build a summary prompt from the first few and last few messages
|
||||
let mut summary_messages = Vec::new();
|
||||
|
||||
// Add system message to guide the description
|
||||
summary_messages.push(crate::types::Message::system(
|
||||
"Summarize this conversation in 1-2 short sentences (max 100 characters). \
|
||||
Focus on the main topic or question being discussed. Be concise and descriptive."
|
||||
.to_string(),
|
||||
));
|
||||
|
||||
// Include first message
|
||||
if let Some(first) = conv.messages.first() {
|
||||
summary_messages.push(first.clone());
|
||||
}
|
||||
|
||||
// Include a middle message if conversation is long enough
|
||||
if conv.messages.len() > 4 {
|
||||
if let Some(mid) = conv.messages.get(conv.messages.len() / 2) {
|
||||
summary_messages.push(mid.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Include last message
|
||||
if let Some(last) = conv.messages.last() {
|
||||
if conv.messages.len() > 1 {
|
||||
summary_messages.push(last.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Create a summarization request
|
||||
let request = crate::types::ChatRequest {
|
||||
model: conv.model.clone(),
|
||||
messages: summary_messages,
|
||||
parameters: crate::types::ChatParameters {
|
||||
temperature: Some(0.3), // Lower temperature for more focused summaries
|
||||
max_tokens: Some(50), // Keep it short
|
||||
stream: false,
|
||||
extra: std::collections::HashMap::new(),
|
||||
},
|
||||
tools: None,
|
||||
};
|
||||
|
||||
// Get the summary from the provider
|
||||
match self.provider.chat(request).await {
|
||||
Ok(response) => {
|
||||
let description = response.message.content.trim().to_string();
|
||||
|
||||
// If description is empty, use fallback
|
||||
if description.is_empty() {
|
||||
let first_msg = &conv.messages[0];
|
||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||
return Ok(format!(
|
||||
"{}{}",
|
||||
preview,
|
||||
if first_msg.content.len() > 50 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
let truncated = if description.len() > 100 {
|
||||
format!("{}...", description.chars().take(97).collect::<String>())
|
||||
} else {
|
||||
description
|
||||
};
|
||||
Ok(truncated)
|
||||
}
|
||||
Err(_e) => {
|
||||
// Fallback to simple description if AI generation fails
|
||||
let first_msg = &conv.messages[0];
|
||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||
Ok(format!(
|
||||
"{}{}",
|
||||
preview,
|
||||
if first_msg.content.len() > 50 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
559
crates/owlen-core/src/storage.rs
Normal file
559
crates/owlen-core/src/storage.rs
Normal file
@@ -0,0 +1,559 @@
|
||||
//! Session persistence and storage management backed by SQLite
|
||||
|
||||
use crate::types::Conversation;
|
||||
use crate::{Error, Result};
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
|
||||
use sqlx::{Pool, Row, Sqlite};
|
||||
use std::fs;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Metadata about a saved session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionMeta {
|
||||
/// Conversation ID
|
||||
pub id: Uuid,
|
||||
/// Optional session name
|
||||
pub name: Option<String>,
|
||||
/// Optional AI-generated description
|
||||
pub description: Option<String>,
|
||||
/// Number of messages in the conversation
|
||||
pub message_count: usize,
|
||||
/// Model used
|
||||
pub model: String,
|
||||
/// When the session was created
|
||||
pub created_at: SystemTime,
|
||||
/// When the session was last updated
|
||||
pub updated_at: SystemTime,
|
||||
}
|
||||
|
||||
/// Storage manager for persisting conversations in SQLite
|
||||
pub struct StorageManager {
|
||||
pool: Pool<Sqlite>,
|
||||
database_path: PathBuf,
|
||||
}
|
||||
|
||||
impl StorageManager {
|
||||
/// Create a new storage manager using the default database path
|
||||
pub async fn new() -> Result<Self> {
|
||||
let db_path = Self::default_database_path()?;
|
||||
Self::with_database_path(db_path).await
|
||||
}
|
||||
|
||||
/// Create a storage manager using the provided database path
|
||||
pub async fn with_database_path(database_path: PathBuf) -> Result<Self> {
|
||||
if let Some(parent) = database_path.parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
Error::Storage(format!(
|
||||
"Failed to create database directory {parent:?}: {e}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
let options = SqliteConnectOptions::from_str(&format!(
|
||||
"sqlite://{}",
|
||||
database_path
|
||||
.to_str()
|
||||
.ok_or_else(|| Error::Storage("Invalid database path".to_string()))?
|
||||
))
|
||||
.map_err(|e| Error::Storage(format!("Invalid database URL: {e}")))?
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to connect to database: {e}")))?;
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to run database migrations: {e}")))?;
|
||||
|
||||
let storage = Self {
|
||||
pool,
|
||||
database_path,
|
||||
};
|
||||
|
||||
storage.try_migrate_legacy_sessions().await?;
|
||||
|
||||
Ok(storage)
|
||||
}
|
||||
|
||||
/// Save a conversation. Existing entries are updated in-place.
|
||||
pub async fn save_conversation(
|
||||
&self,
|
||||
conversation: &Conversation,
|
||||
name: Option<String>,
|
||||
) -> Result<()> {
|
||||
self.save_conversation_with_description(conversation, name, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Save a conversation with an optional description override
|
||||
pub async fn save_conversation_with_description(
|
||||
&self,
|
||||
conversation: &Conversation,
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<()> {
|
||||
let mut serialized = conversation.clone();
|
||||
if name.is_some() {
|
||||
serialized.name = name.clone();
|
||||
}
|
||||
if description.is_some() {
|
||||
serialized.description = description.clone();
|
||||
}
|
||||
|
||||
let data = serde_json::to_string(&serialized)
|
||||
.map_err(|e| Error::Storage(format!("Failed to serialize conversation: {e}")))?;
|
||||
|
||||
let created_at = to_epoch_seconds(serialized.created_at);
|
||||
let updated_at = to_epoch_seconds(serialized.updated_at);
|
||||
let message_count = serialized.messages.len() as i64;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO conversations (
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
model,
|
||||
message_count,
|
||||
created_at,
|
||||
updated_at,
|
||||
data
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
model = excluded.model,
|
||||
message_count = excluded.message_count,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at,
|
||||
data = excluded.data
|
||||
"#,
|
||||
)
|
||||
.bind(serialized.id.to_string())
|
||||
.bind(name.or(serialized.name.clone()))
|
||||
.bind(description.or(serialized.description.clone()))
|
||||
.bind(&serialized.model)
|
||||
.bind(message_count)
|
||||
.bind(created_at)
|
||||
.bind(updated_at)
|
||||
.bind(data)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to save conversation: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a conversation by ID
|
||||
pub async fn load_conversation(&self, id: Uuid) -> Result<Conversation> {
|
||||
let record = sqlx::query(r#"SELECT data FROM conversations WHERE id = ?1"#)
|
||||
.bind(id.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to load conversation: {e}")))?;
|
||||
|
||||
let row =
|
||||
record.ok_or_else(|| Error::Storage(format!("No conversation found with id {id}")))?;
|
||||
|
||||
let data: String = row
|
||||
.try_get("data")
|
||||
.map_err(|e| Error::Storage(format!("Failed to read conversation payload: {e}")))?;
|
||||
|
||||
serde_json::from_str(&data)
|
||||
.map_err(|e| Error::Storage(format!("Failed to deserialize conversation: {e}")))
|
||||
}
|
||||
|
||||
/// List metadata for all saved conversations ordered by most recent update
|
||||
pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, name, description, model, message_count, created_at, updated_at
|
||||
FROM conversations
|
||||
ORDER BY updated_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to list sessions: {e}")))?;
|
||||
|
||||
let mut sessions = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id_text: String = row
|
||||
.try_get("id")
|
||||
.map_err(|e| Error::Storage(format!("Failed to read id column: {e}")))?;
|
||||
let id = Uuid::parse_str(&id_text)
|
||||
.map_err(|e| Error::Storage(format!("Invalid UUID in storage: {e}")))?;
|
||||
|
||||
let message_count: i64 = row
|
||||
.try_get("message_count")
|
||||
.map_err(|e| Error::Storage(format!("Failed to read message count: {e}")))?;
|
||||
|
||||
let created_at: i64 = row
|
||||
.try_get("created_at")
|
||||
.map_err(|e| Error::Storage(format!("Failed to read created_at: {e}")))?;
|
||||
let updated_at: i64 = row
|
||||
.try_get("updated_at")
|
||||
.map_err(|e| Error::Storage(format!("Failed to read updated_at: {e}")))?;
|
||||
|
||||
sessions.push(SessionMeta {
|
||||
id,
|
||||
name: row
|
||||
.try_get("name")
|
||||
.map_err(|e| Error::Storage(format!("Failed to read name: {e}")))?,
|
||||
description: row
|
||||
.try_get("description")
|
||||
.map_err(|e| Error::Storage(format!("Failed to read description: {e}")))?,
|
||||
model: row
|
||||
.try_get("model")
|
||||
.map_err(|e| Error::Storage(format!("Failed to read model: {e}")))?,
|
||||
message_count: message_count as usize,
|
||||
created_at: from_epoch_seconds(created_at),
|
||||
updated_at: from_epoch_seconds(updated_at),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// Delete a conversation by ID
|
||||
pub async fn delete_session(&self, id: Uuid) -> Result<()> {
|
||||
sqlx::query("DELETE FROM conversations WHERE id = ?1")
|
||||
.bind(id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to delete conversation: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn store_secure_item(
|
||||
&self,
|
||||
key: &str,
|
||||
plaintext: &[u8],
|
||||
master_key: &[u8],
|
||||
) -> Result<()> {
|
||||
let cipher = create_cipher(master_key)?;
|
||||
let nonce_bytes = generate_nonce()?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|e| Error::Storage(format!("Failed to encrypt secure item: {e}")))?;
|
||||
|
||||
let now = to_epoch_seconds(SystemTime::now());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO secure_items (key, nonce, ciphertext, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
nonce = excluded.nonce,
|
||||
ciphertext = excluded.ciphertext,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(key)
|
||||
.bind(&nonce_bytes[..])
|
||||
.bind(&ciphertext[..])
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to store secure item: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_secure_item(&self, key: &str, master_key: &[u8]) -> Result<Option<Vec<u8>>> {
|
||||
let record = sqlx::query("SELECT nonce, ciphertext FROM secure_items WHERE key = ?1")
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to load secure item: {e}")))?;
|
||||
|
||||
let Some(row) = record else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let nonce_bytes: Vec<u8> = row
|
||||
.try_get("nonce")
|
||||
.map_err(|e| Error::Storage(format!("Failed to read secure item nonce: {e}")))?;
|
||||
let ciphertext: Vec<u8> = row
|
||||
.try_get("ciphertext")
|
||||
.map_err(|e| Error::Storage(format!("Failed to read secure item ciphertext: {e}")))?;
|
||||
|
||||
if nonce_bytes.len() != 12 {
|
||||
return Err(Error::Storage(
|
||||
"Invalid nonce length for secure item".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let cipher = create_cipher(master_key)?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext.as_ref())
|
||||
.map_err(|e| Error::Storage(format!("Failed to decrypt secure item: {e}")))?;
|
||||
|
||||
Ok(Some(plaintext))
|
||||
}
|
||||
|
||||
pub async fn delete_secure_item(&self, key: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM secure_items WHERE key = ?1")
|
||||
.bind(key)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to delete secure item: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_secure_items(&self) -> Result<()> {
|
||||
sqlx::query("DELETE FROM secure_items")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to clear secure items: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Database location used by this storage manager
|
||||
pub fn database_path(&self) -> &Path {
|
||||
&self.database_path
|
||||
}
|
||||
|
||||
/// Determine default database path (platform specific)
|
||||
pub fn default_database_path() -> Result<PathBuf> {
|
||||
let data_dir = dirs::data_local_dir()
|
||||
.ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?;
|
||||
Ok(data_dir.join("owlen").join("owlen.db"))
|
||||
}
|
||||
|
||||
fn legacy_sessions_dir() -> Result<PathBuf> {
|
||||
let data_dir = dirs::data_local_dir()
|
||||
.ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?;
|
||||
Ok(data_dir.join("owlen").join("sessions"))
|
||||
}
|
||||
|
||||
async fn database_has_records(&self) -> Result<bool> {
|
||||
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM conversations")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Storage(format!("Failed to inspect database: {e}")))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn try_migrate_legacy_sessions(&self) -> Result<()> {
|
||||
if self.database_has_records().await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let legacy_dir = match Self::legacy_sessions_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
if !legacy_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(&legacy_dir).map_err(|e| {
|
||||
Error::Storage(format!("Failed to read legacy sessions directory: {e}"))
|
||||
})?;
|
||||
|
||||
let mut json_files = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
json_files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if json_files.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !io::stdin().is_terminal() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"Legacy OWLEN session files were found in {}.",
|
||||
legacy_dir.display()
|
||||
);
|
||||
if !prompt_yes_no("Migrate them to the new SQLite storage? (y/N) ")? {
|
||||
println!("Skipping legacy session migration.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Migrating legacy sessions...");
|
||||
let mut migrated = 0usize;
|
||||
for path in &json_files {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(content) => match serde_json::from_str::<Conversation>(&content) {
|
||||
Ok(conversation) => {
|
||||
if let Err(err) = self
|
||||
.save_conversation_with_description(
|
||||
&conversation,
|
||||
conversation.name.clone(),
|
||||
conversation.description.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
println!(" • Failed to migrate {}: {}", path.display(), err);
|
||||
} else {
|
||||
migrated += 1;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
println!(
|
||||
" • Failed to parse conversation {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
println!(" • Failed to read {}: {}", path.display(), err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if migrated > 0 {
|
||||
if let Err(err) = archive_legacy_directory(&legacy_dir) {
|
||||
println!(
|
||||
"Warning: migrated sessions but failed to archive legacy directory: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Migrated {} legacy sessions.", migrated);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn to_epoch_seconds(time: SystemTime) -> i64 {
|
||||
match time.duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_secs() as i64,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_epoch_seconds(seconds: i64) -> SystemTime {
|
||||
UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64)
|
||||
}
|
||||
|
||||
fn prompt_yes_no(prompt: &str) -> Result<bool> {
|
||||
print!("{}", prompt);
|
||||
io::stdout()
|
||||
.flush()
|
||||
.map_err(|e| Error::Storage(format!("Failed to flush stdout: {e}")))?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.map_err(|e| Error::Storage(format!("Failed to read input: {e}")))?;
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
Ok(matches!(trimmed.as_str(), "y" | "yes"))
|
||||
}
|
||||
|
||||
fn archive_legacy_directory(legacy_dir: &Path) -> Result<()> {
|
||||
let mut backup_dir = legacy_dir.with_file_name("sessions_legacy_backup");
|
||||
let mut counter = 1;
|
||||
while backup_dir.exists() {
|
||||
backup_dir = legacy_dir.with_file_name(format!("sessions_legacy_backup_{}", counter));
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
fs::rename(legacy_dir, &backup_dir).map_err(|e| {
|
||||
Error::Storage(format!(
|
||||
"Failed to archive legacy sessions directory {}: {}",
|
||||
legacy_dir.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
println!("Legacy session files archived to {}", backup_dir.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_cipher(master_key: &[u8]) -> Result<Aes256Gcm> {
|
||||
if master_key.len() != 32 {
|
||||
return Err(Error::Storage(
|
||||
"Master key must be 32 bytes for AES-256-GCM".to_string(),
|
||||
));
|
||||
}
|
||||
Aes256Gcm::new_from_slice(master_key).map_err(|_| {
|
||||
Error::Storage("Failed to initialize cipher with provided master key".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_nonce() -> Result<[u8; 12]> {
|
||||
let mut nonce = [0u8; 12];
|
||||
SystemRandom::new()
|
||||
.fill(&mut nonce)
|
||||
.map_err(|_| Error::Storage("Failed to generate nonce".to_string()))?;
|
||||
Ok(nonce)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{Conversation, Message};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn sample_conversation() -> Conversation {
|
||||
Conversation {
|
||||
id: Uuid::new_v4(),
|
||||
name: Some("Test conversation".to_string()),
|
||||
description: Some("A sample conversation".to_string()),
|
||||
messages: vec![
|
||||
Message::user("Hello".to_string()),
|
||||
Message::assistant("Hi".to_string()),
|
||||
],
|
||||
model: "test-model".to_string(),
|
||||
created_at: SystemTime::now(),
|
||||
updated_at: SystemTime::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_storage_lifecycle() {
|
||||
let temp_dir = tempdir().expect("failed to create temp dir");
|
||||
let db_path = temp_dir.path().join("owlen.db");
|
||||
let storage = StorageManager::with_database_path(db_path).await.unwrap();
|
||||
|
||||
let conversation = sample_conversation();
|
||||
storage
|
||||
.save_conversation(&conversation, None)
|
||||
.await
|
||||
.expect("failed to save conversation");
|
||||
|
||||
let sessions = storage.list_sessions().await.unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, conversation.id);
|
||||
|
||||
let loaded = storage.load_conversation(conversation.id).await.unwrap();
|
||||
assert_eq!(loaded.messages.len(), 2);
|
||||
|
||||
storage
|
||||
.delete_session(conversation.id)
|
||||
.await
|
||||
.expect("failed to delete conversation");
|
||||
let sessions = storage.list_sessions().await.unwrap();
|
||||
assert!(sessions.is_empty());
|
||||
}
|
||||
}
|
||||
645
crates/owlen-core/src/theme.rs
Normal file
645
crates/owlen-core/src/theme.rs
Normal file
@@ -0,0 +1,645 @@
|
||||
//! Theming system for OWLEN TUI
|
||||
//!
|
||||
//! Provides customizable color schemes for all UI components.
|
||||
|
||||
use ratatui::style::Color;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A complete theme definition for OWLEN TUI
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Theme {
|
||||
/// Name of the theme
|
||||
pub name: String,
|
||||
|
||||
/// Default text color
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub text: Color,
|
||||
|
||||
/// Default background color
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub background: Color,
|
||||
|
||||
/// Border color for focused panels
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub focused_panel_border: Color,
|
||||
|
||||
/// Border color for unfocused panels
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub unfocused_panel_border: Color,
|
||||
|
||||
/// Color for user message role indicator
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub user_message_role: Color,
|
||||
|
||||
/// Color for assistant message role indicator
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub assistant_message_role: Color,
|
||||
|
||||
/// Color for thinking panel title
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub thinking_panel_title: Color,
|
||||
|
||||
/// Background color for command bar
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub command_bar_background: Color,
|
||||
|
||||
/// Status line background color
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub status_background: Color,
|
||||
|
||||
/// Color for Normal mode indicator
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub mode_normal: Color,
|
||||
|
||||
/// Color for Editing mode indicator
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub mode_editing: Color,
|
||||
|
||||
/// Color for Model Selection mode indicator
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub mode_model_selection: Color,
|
||||
|
||||
/// Color for Provider Selection mode indicator
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub mode_provider_selection: Color,
|
||||
|
||||
/// Color for Help mode indicator
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub mode_help: Color,
|
||||
|
||||
/// Color for Visual mode indicator
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub mode_visual: Color,
|
||||
|
||||
/// Color for Command mode indicator
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub mode_command: Color,
|
||||
|
||||
/// Selection/highlight background color
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub selection_bg: Color,
|
||||
|
||||
/// Selection/highlight foreground color
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub selection_fg: Color,
|
||||
|
||||
/// Cursor indicator color
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub cursor: Color,
|
||||
|
||||
/// Placeholder text color
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub placeholder: Color,
|
||||
|
||||
/// Warning/error message color
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub error: Color,
|
||||
|
||||
/// Success/info message color
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub info: Color,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
default_dark()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default themes directory path
|
||||
pub fn default_themes_dir() -> PathBuf {
|
||||
let config_dir = PathBuf::from(shellexpand::tilde(crate::config::DEFAULT_CONFIG_PATH).as_ref())
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("~/.config/owlen"));
|
||||
|
||||
config_dir.join("themes")
|
||||
}
|
||||
|
||||
/// Load all available themes (built-in + custom)
|
||||
pub fn load_all_themes() -> HashMap<String, Theme> {
|
||||
let mut themes = HashMap::new();
|
||||
|
||||
// Load built-in themes
|
||||
for (name, theme) in built_in_themes() {
|
||||
themes.insert(name, theme);
|
||||
}
|
||||
|
||||
// Load custom themes from disk
|
||||
let themes_dir = default_themes_dir();
|
||||
if let Ok(entries) = fs::read_dir(&themes_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("toml") {
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
match load_theme_from_file(&path) {
|
||||
Ok(theme) => {
|
||||
themes.insert(name.clone(), theme);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to load custom theme '{}': {}", name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
themes
|
||||
}
|
||||
|
||||
/// Load a theme from a TOML file
|
||||
pub fn load_theme_from_file(path: &Path) -> Result<Theme, String> {
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read theme file: {}", e))?;
|
||||
|
||||
toml::from_str(&content).map_err(|e| format!("Failed to parse theme file: {}", e))
|
||||
}
|
||||
|
||||
/// Get a theme by name (built-in or custom)
|
||||
pub fn get_theme(name: &str) -> Option<Theme> {
|
||||
load_all_themes().get(name).cloned()
|
||||
}
|
||||
|
||||
/// Get all built-in themes (embedded in the binary)
|
||||
pub fn built_in_themes() -> HashMap<String, Theme> {
|
||||
let mut themes = HashMap::new();
|
||||
|
||||
// Load embedded theme files
|
||||
let embedded_themes = [
|
||||
(
|
||||
"default_dark",
|
||||
include_str!("../../../themes/default_dark.toml"),
|
||||
),
|
||||
(
|
||||
"default_light",
|
||||
include_str!("../../../themes/default_light.toml"),
|
||||
),
|
||||
("gruvbox", include_str!("../../../themes/gruvbox.toml")),
|
||||
("dracula", include_str!("../../../themes/dracula.toml")),
|
||||
("solarized", include_str!("../../../themes/solarized.toml")),
|
||||
(
|
||||
"midnight-ocean",
|
||||
include_str!("../../../themes/midnight-ocean.toml"),
|
||||
),
|
||||
("rose-pine", include_str!("../../../themes/rose-pine.toml")),
|
||||
("monokai", include_str!("../../../themes/monokai.toml")),
|
||||
(
|
||||
"material-dark",
|
||||
include_str!("../../../themes/material-dark.toml"),
|
||||
),
|
||||
(
|
||||
"material-light",
|
||||
include_str!("../../../themes/material-light.toml"),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, content) in embedded_themes {
|
||||
match toml::from_str::<Theme>(content) {
|
||||
Ok(theme) => {
|
||||
themes.insert(name.to_string(), theme);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to parse built-in theme '{}': {}", name, e);
|
||||
// Fallback to hardcoded version if parsing fails
|
||||
if let Some(fallback) = get_fallback_theme(name) {
|
||||
themes.insert(name.to_string(), fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
themes
|
||||
}
|
||||
|
||||
/// Get fallback hardcoded theme (used if embedded TOML fails to parse)
|
||||
fn get_fallback_theme(name: &str) -> Option<Theme> {
|
||||
match name {
|
||||
"default_dark" => Some(default_dark()),
|
||||
"default_light" => Some(default_light()),
|
||||
"gruvbox" => Some(gruvbox()),
|
||||
"dracula" => Some(dracula()),
|
||||
"solarized" => Some(solarized()),
|
||||
"midnight-ocean" => Some(midnight_ocean()),
|
||||
"rose-pine" => Some(rose_pine()),
|
||||
"monokai" => Some(monokai()),
|
||||
"material-dark" => Some(material_dark()),
|
||||
"material-light" => Some(material_light()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default dark theme
|
||||
fn default_dark() -> Theme {
|
||||
Theme {
|
||||
name: "default_dark".to_string(),
|
||||
text: Color::White,
|
||||
background: Color::Black,
|
||||
focused_panel_border: Color::LightMagenta,
|
||||
unfocused_panel_border: Color::Rgb(95, 20, 135),
|
||||
user_message_role: Color::LightBlue,
|
||||
assistant_message_role: Color::Yellow,
|
||||
thinking_panel_title: Color::LightMagenta,
|
||||
command_bar_background: Color::Black,
|
||||
status_background: Color::Black,
|
||||
mode_normal: Color::LightBlue,
|
||||
mode_editing: Color::LightGreen,
|
||||
mode_model_selection: Color::LightYellow,
|
||||
mode_provider_selection: Color::LightCyan,
|
||||
mode_help: Color::LightMagenta,
|
||||
mode_visual: Color::Magenta,
|
||||
mode_command: Color::Yellow,
|
||||
selection_bg: Color::LightBlue,
|
||||
selection_fg: Color::Black,
|
||||
cursor: Color::Magenta,
|
||||
placeholder: Color::DarkGray,
|
||||
error: Color::Red,
|
||||
info: Color::LightGreen,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default light theme
|
||||
fn default_light() -> Theme {
|
||||
Theme {
|
||||
name: "default_light".to_string(),
|
||||
text: Color::Black,
|
||||
background: Color::White,
|
||||
focused_panel_border: Color::Rgb(74, 144, 226),
|
||||
unfocused_panel_border: Color::Rgb(221, 221, 221),
|
||||
user_message_role: Color::Rgb(0, 85, 164),
|
||||
assistant_message_role: Color::Rgb(142, 68, 173),
|
||||
thinking_panel_title: Color::Rgb(142, 68, 173),
|
||||
command_bar_background: Color::White,
|
||||
status_background: Color::White,
|
||||
mode_normal: Color::Rgb(0, 85, 164),
|
||||
mode_editing: Color::Rgb(46, 139, 87),
|
||||
mode_model_selection: Color::Rgb(181, 137, 0),
|
||||
mode_provider_selection: Color::Rgb(0, 139, 139),
|
||||
mode_help: Color::Rgb(142, 68, 173),
|
||||
mode_visual: Color::Rgb(142, 68, 173),
|
||||
mode_command: Color::Rgb(181, 137, 0),
|
||||
selection_bg: Color::Rgb(164, 200, 240),
|
||||
selection_fg: Color::Black,
|
||||
cursor: Color::Rgb(217, 95, 2),
|
||||
placeholder: Color::Gray,
|
||||
error: Color::Rgb(192, 57, 43),
|
||||
info: Color::Green,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gruvbox theme
|
||||
fn gruvbox() -> Theme {
|
||||
Theme {
|
||||
name: "gruvbox".to_string(),
|
||||
text: Color::Rgb(235, 219, 178), // #ebdbb2
|
||||
background: Color::Rgb(40, 40, 40), // #282828
|
||||
focused_panel_border: Color::Rgb(254, 128, 25), // #fe8019 (orange)
|
||||
unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64
|
||||
user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green)
|
||||
assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue)
|
||||
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
|
||||
command_bar_background: Color::Rgb(60, 56, 54), // #3c3836
|
||||
status_background: Color::Rgb(60, 56, 54),
|
||||
mode_normal: Color::Rgb(131, 165, 152), // blue
|
||||
mode_editing: Color::Rgb(184, 187, 38), // green
|
||||
mode_model_selection: Color::Rgb(250, 189, 47), // yellow
|
||||
mode_provider_selection: Color::Rgb(142, 192, 124), // aqua
|
||||
mode_help: Color::Rgb(211, 134, 155), // purple
|
||||
mode_visual: Color::Rgb(254, 128, 25), // orange
|
||||
mode_command: Color::Rgb(250, 189, 47), // yellow
|
||||
selection_bg: Color::Rgb(80, 73, 69),
|
||||
selection_fg: Color::Rgb(235, 219, 178),
|
||||
cursor: Color::Rgb(254, 128, 25),
|
||||
placeholder: Color::Rgb(102, 92, 84),
|
||||
error: Color::Rgb(251, 73, 52), // #fb4934
|
||||
info: Color::Rgb(184, 187, 38),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dracula theme
|
||||
fn dracula() -> Theme {
|
||||
Theme {
|
||||
name: "dracula".to_string(),
|
||||
text: Color::Rgb(248, 248, 242), // #f8f8f2
|
||||
background: Color::Rgb(40, 42, 54), // #282a36
|
||||
focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
|
||||
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
|
||||
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan)
|
||||
assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
|
||||
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple)
|
||||
command_bar_background: Color::Rgb(68, 71, 90),
|
||||
status_background: Color::Rgb(68, 71, 90),
|
||||
mode_normal: Color::Rgb(139, 233, 253),
|
||||
mode_editing: Color::Rgb(80, 250, 123), // #50fa7b (green)
|
||||
mode_model_selection: Color::Rgb(241, 250, 140), // #f1fa8c (yellow)
|
||||
mode_provider_selection: Color::Rgb(139, 233, 253),
|
||||
mode_help: Color::Rgb(189, 147, 249),
|
||||
mode_visual: Color::Rgb(255, 121, 198),
|
||||
mode_command: Color::Rgb(241, 250, 140),
|
||||
selection_bg: Color::Rgb(68, 71, 90),
|
||||
selection_fg: Color::Rgb(248, 248, 242),
|
||||
cursor: Color::Rgb(255, 121, 198),
|
||||
placeholder: Color::Rgb(98, 114, 164),
|
||||
error: Color::Rgb(255, 85, 85), // #ff5555
|
||||
info: Color::Rgb(80, 250, 123),
|
||||
}
|
||||
}
|
||||
|
||||
/// Solarized Dark theme
|
||||
fn solarized() -> Theme {
|
||||
Theme {
|
||||
name: "solarized".to_string(),
|
||||
text: Color::Rgb(131, 148, 150), // #839496 (base0)
|
||||
background: Color::Rgb(0, 43, 54), // #002b36 (base03)
|
||||
focused_panel_border: Color::Rgb(38, 139, 210), // #268bd2 (blue)
|
||||
unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02)
|
||||
user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan)
|
||||
assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange)
|
||||
thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet)
|
||||
command_bar_background: Color::Rgb(7, 54, 66),
|
||||
status_background: Color::Rgb(7, 54, 66),
|
||||
mode_normal: Color::Rgb(38, 139, 210), // blue
|
||||
mode_editing: Color::Rgb(133, 153, 0), // #859900 (green)
|
||||
mode_model_selection: Color::Rgb(181, 137, 0), // #b58900 (yellow)
|
||||
mode_provider_selection: Color::Rgb(42, 161, 152), // cyan
|
||||
mode_help: Color::Rgb(108, 113, 196), // violet
|
||||
mode_visual: Color::Rgb(211, 54, 130), // #d33682 (magenta)
|
||||
mode_command: Color::Rgb(181, 137, 0), // yellow
|
||||
selection_bg: Color::Rgb(7, 54, 66),
|
||||
selection_fg: Color::Rgb(147, 161, 161),
|
||||
cursor: Color::Rgb(211, 54, 130),
|
||||
placeholder: Color::Rgb(88, 110, 117),
|
||||
error: Color::Rgb(220, 50, 47), // #dc322f (red)
|
||||
info: Color::Rgb(133, 153, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Midnight Ocean theme
|
||||
fn midnight_ocean() -> Theme {
|
||||
Theme {
|
||||
name: "midnight-ocean".to_string(),
|
||||
text: Color::Rgb(192, 202, 245),
|
||||
background: Color::Rgb(13, 17, 23),
|
||||
focused_panel_border: Color::Rgb(88, 166, 255),
|
||||
unfocused_panel_border: Color::Rgb(48, 54, 61),
|
||||
user_message_role: Color::Rgb(121, 192, 255),
|
||||
assistant_message_role: Color::Rgb(137, 221, 255),
|
||||
thinking_panel_title: Color::Rgb(158, 206, 106),
|
||||
command_bar_background: Color::Rgb(22, 27, 34),
|
||||
status_background: Color::Rgb(22, 27, 34),
|
||||
mode_normal: Color::Rgb(121, 192, 255),
|
||||
mode_editing: Color::Rgb(158, 206, 106),
|
||||
mode_model_selection: Color::Rgb(255, 212, 59),
|
||||
mode_provider_selection: Color::Rgb(137, 221, 255),
|
||||
mode_help: Color::Rgb(255, 115, 157),
|
||||
mode_visual: Color::Rgb(246, 140, 245),
|
||||
mode_command: Color::Rgb(255, 212, 59),
|
||||
selection_bg: Color::Rgb(56, 139, 253),
|
||||
selection_fg: Color::Rgb(13, 17, 23),
|
||||
cursor: Color::Rgb(246, 140, 245),
|
||||
placeholder: Color::Rgb(110, 118, 129),
|
||||
error: Color::Rgb(248, 81, 73),
|
||||
info: Color::Rgb(158, 206, 106),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rose Pine theme
|
||||
fn rose_pine() -> Theme {
|
||||
Theme {
|
||||
name: "rose-pine".to_string(),
|
||||
text: Color::Rgb(224, 222, 244), // #e0def4
|
||||
background: Color::Rgb(25, 23, 36), // #191724
|
||||
focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love)
|
||||
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
|
||||
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam)
|
||||
assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light)
|
||||
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
|
||||
command_bar_background: Color::Rgb(38, 35, 58),
|
||||
status_background: Color::Rgb(38, 35, 58),
|
||||
mode_normal: Color::Rgb(156, 207, 216),
|
||||
mode_editing: Color::Rgb(235, 188, 186), // #ebbcba (rose)
|
||||
mode_model_selection: Color::Rgb(246, 193, 119),
|
||||
mode_provider_selection: Color::Rgb(49, 116, 143),
|
||||
mode_help: Color::Rgb(196, 167, 231),
|
||||
mode_visual: Color::Rgb(235, 111, 146),
|
||||
mode_command: Color::Rgb(246, 193, 119),
|
||||
selection_bg: Color::Rgb(64, 61, 82),
|
||||
selection_fg: Color::Rgb(224, 222, 244),
|
||||
cursor: Color::Rgb(235, 111, 146),
|
||||
placeholder: Color::Rgb(110, 106, 134),
|
||||
error: Color::Rgb(235, 111, 146),
|
||||
info: Color::Rgb(156, 207, 216),
|
||||
}
|
||||
}
|
||||
|
||||
/// Monokai theme
|
||||
fn monokai() -> Theme {
|
||||
Theme {
|
||||
name: "monokai".to_string(),
|
||||
text: Color::Rgb(248, 248, 242), // #f8f8f2
|
||||
background: Color::Rgb(39, 40, 34), // #272822
|
||||
focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink)
|
||||
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
|
||||
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan)
|
||||
assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple)
|
||||
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
|
||||
command_bar_background: Color::Rgb(39, 40, 34),
|
||||
status_background: Color::Rgb(39, 40, 34),
|
||||
mode_normal: Color::Rgb(102, 217, 239),
|
||||
mode_editing: Color::Rgb(166, 226, 46), // #a6e22e (green)
|
||||
mode_model_selection: Color::Rgb(230, 219, 116),
|
||||
mode_provider_selection: Color::Rgb(102, 217, 239),
|
||||
mode_help: Color::Rgb(174, 129, 255),
|
||||
mode_visual: Color::Rgb(249, 38, 114),
|
||||
mode_command: Color::Rgb(230, 219, 116),
|
||||
selection_bg: Color::Rgb(117, 113, 94),
|
||||
selection_fg: Color::Rgb(248, 248, 242),
|
||||
cursor: Color::Rgb(249, 38, 114),
|
||||
placeholder: Color::Rgb(117, 113, 94),
|
||||
error: Color::Rgb(249, 38, 114),
|
||||
info: Color::Rgb(166, 226, 46),
|
||||
}
|
||||
}
|
||||
|
||||
/// Material Dark theme
|
||||
fn material_dark() -> Theme {
|
||||
Theme {
|
||||
name: "material-dark".to_string(),
|
||||
text: Color::Rgb(238, 255, 255), // #eeffff
|
||||
background: Color::Rgb(38, 50, 56), // #263238
|
||||
focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan)
|
||||
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
|
||||
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue)
|
||||
assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple)
|
||||
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
|
||||
command_bar_background: Color::Rgb(33, 43, 48),
|
||||
status_background: Color::Rgb(33, 43, 48),
|
||||
mode_normal: Color::Rgb(130, 170, 255),
|
||||
mode_editing: Color::Rgb(195, 232, 141), // #c3e88d (green)
|
||||
mode_model_selection: Color::Rgb(255, 203, 107),
|
||||
mode_provider_selection: Color::Rgb(128, 203, 196),
|
||||
mode_help: Color::Rgb(199, 146, 234),
|
||||
mode_visual: Color::Rgb(240, 113, 120), // #f07178 (red)
|
||||
mode_command: Color::Rgb(255, 203, 107),
|
||||
selection_bg: Color::Rgb(84, 110, 122),
|
||||
selection_fg: Color::Rgb(238, 255, 255),
|
||||
cursor: Color::Rgb(255, 204, 0),
|
||||
placeholder: Color::Rgb(84, 110, 122),
|
||||
error: Color::Rgb(240, 113, 120),
|
||||
info: Color::Rgb(195, 232, 141),
|
||||
}
|
||||
}
|
||||
|
||||
/// Material Light theme
|
||||
fn material_light() -> Theme {
|
||||
Theme {
|
||||
name: "material-light".to_string(),
|
||||
text: Color::Rgb(33, 33, 33),
|
||||
background: Color::Rgb(236, 239, 241),
|
||||
focused_panel_border: Color::Rgb(0, 150, 136),
|
||||
unfocused_panel_border: Color::Rgb(176, 190, 197),
|
||||
user_message_role: Color::Rgb(68, 138, 255),
|
||||
assistant_message_role: Color::Rgb(124, 77, 255),
|
||||
thinking_panel_title: Color::Rgb(245, 124, 0),
|
||||
command_bar_background: Color::Rgb(255, 255, 255),
|
||||
status_background: Color::Rgb(255, 255, 255),
|
||||
mode_normal: Color::Rgb(68, 138, 255),
|
||||
mode_editing: Color::Rgb(56, 142, 60),
|
||||
mode_model_selection: Color::Rgb(245, 124, 0),
|
||||
mode_provider_selection: Color::Rgb(0, 150, 136),
|
||||
mode_help: Color::Rgb(124, 77, 255),
|
||||
mode_visual: Color::Rgb(211, 47, 47),
|
||||
mode_command: Color::Rgb(245, 124, 0),
|
||||
selection_bg: Color::Rgb(176, 190, 197),
|
||||
selection_fg: Color::Rgb(33, 33, 33),
|
||||
cursor: Color::Rgb(194, 24, 91),
|
||||
placeholder: Color::Rgb(144, 164, 174),
|
||||
error: Color::Rgb(211, 47, 47),
|
||||
info: Color::Rgb(56, 142, 60),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for color serialization/deserialization
|
||||
|
||||
fn deserialize_color<'de, D>(deserializer: D) -> Result<Color, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
parse_color(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn serialize_color<S>(color: &Color, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let s = color_to_string(color);
|
||||
serializer.serialize_str(&s)
|
||||
}
|
||||
|
||||
fn parse_color(s: &str) -> Result<Color, String> {
|
||||
if let Some(hex) = s.strip_prefix('#') {
|
||||
if hex.len() == 6 {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16)
|
||||
.map_err(|_| format!("Invalid hex color: {}", s))?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16)
|
||||
.map_err(|_| format!("Invalid hex color: {}", s))?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16)
|
||||
.map_err(|_| format!("Invalid hex color: {}", s))?;
|
||||
return Ok(Color::Rgb(r, g, b));
|
||||
}
|
||||
}
|
||||
|
||||
// Try named colors
|
||||
match s.to_lowercase().as_str() {
|
||||
"black" => Ok(Color::Black),
|
||||
"red" => Ok(Color::Red),
|
||||
"green" => Ok(Color::Green),
|
||||
"yellow" => Ok(Color::Yellow),
|
||||
"blue" => Ok(Color::Blue),
|
||||
"magenta" => Ok(Color::Magenta),
|
||||
"cyan" => Ok(Color::Cyan),
|
||||
"gray" | "grey" => Ok(Color::Gray),
|
||||
"darkgray" | "darkgrey" => Ok(Color::DarkGray),
|
||||
"lightred" => Ok(Color::LightRed),
|
||||
"lightgreen" => Ok(Color::LightGreen),
|
||||
"lightyellow" => Ok(Color::LightYellow),
|
||||
"lightblue" => Ok(Color::LightBlue),
|
||||
"lightmagenta" => Ok(Color::LightMagenta),
|
||||
"lightcyan" => Ok(Color::LightCyan),
|
||||
"white" => Ok(Color::White),
|
||||
_ => Err(format!("Unknown color: {}", s)),
|
||||
}
|
||||
}
|
||||
|
||||
fn color_to_string(color: &Color) -> String {
|
||||
match color {
|
||||
Color::Black => "black".to_string(),
|
||||
Color::Red => "red".to_string(),
|
||||
Color::Green => "green".to_string(),
|
||||
Color::Yellow => "yellow".to_string(),
|
||||
Color::Blue => "blue".to_string(),
|
||||
Color::Magenta => "magenta".to_string(),
|
||||
Color::Cyan => "cyan".to_string(),
|
||||
Color::Gray => "gray".to_string(),
|
||||
Color::DarkGray => "darkgray".to_string(),
|
||||
Color::LightRed => "lightred".to_string(),
|
||||
Color::LightGreen => "lightgreen".to_string(),
|
||||
Color::LightYellow => "lightyellow".to_string(),
|
||||
Color::LightBlue => "lightblue".to_string(),
|
||||
Color::LightMagenta => "lightmagenta".to_string(),
|
||||
Color::LightCyan => "lightcyan".to_string(),
|
||||
Color::White => "white".to_string(),
|
||||
Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b),
|
||||
_ => "#ffffff".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_color_parsing() {
|
||||
assert!(matches!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0))));
|
||||
assert!(matches!(parse_color("red"), Ok(Color::Red)));
|
||||
assert!(matches!(parse_color("lightblue"), Ok(Color::LightBlue)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_built_in_themes() {
|
||||
let themes = built_in_themes();
|
||||
assert!(themes.contains_key("default_dark"));
|
||||
assert!(themes.contains_key("gruvbox"));
|
||||
assert!(themes.contains_key("dracula"));
|
||||
}
|
||||
}
|
||||
147
crates/owlen-core/src/tools/code_exec.rs
Normal file
147
crates/owlen-core/src/tools/code_exec.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{Tool, ToolResult};
|
||||
use crate::sandbox::{SandboxConfig, SandboxedProcess};
|
||||
|
||||
pub struct CodeExecTool {
|
||||
allowed_languages: Arc<Vec<String>>,
|
||||
}
|
||||
|
||||
impl CodeExecTool {
|
||||
pub fn new(allowed_languages: Vec<String>) -> Self {
|
||||
Self {
|
||||
allowed_languages: Arc::new(allowed_languages),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CodeExecTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"code_exec"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Execute code snippets within a sandboxed environment"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": self.allowed_languages.as_slice(),
|
||||
"description": "Language of the code block"
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 10000,
|
||||
"description": "Code to execute"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 300,
|
||||
"default": 30,
|
||||
"description": "Execution timeout in seconds"
|
||||
}
|
||||
},
|
||||
"required": ["language", "code"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> Result<ToolResult> {
|
||||
let start = Instant::now();
|
||||
|
||||
let language = args
|
||||
.get("language")
|
||||
.and_then(Value::as_str)
|
||||
.context("Missing language parameter")?;
|
||||
let code = args
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.context("Missing code parameter")?;
|
||||
let timeout = args.get("timeout").and_then(Value::as_u64).unwrap_or(30);
|
||||
|
||||
if !self.allowed_languages.iter().any(|lang| lang == language) {
|
||||
return Err(anyhow!("Language '{}' not permitted", language));
|
||||
}
|
||||
|
||||
let (command, command_args) = match language {
|
||||
"python" => (
|
||||
"python3".to_string(),
|
||||
vec!["-c".to_string(), code.to_string()],
|
||||
),
|
||||
"javascript" => ("node".to_string(), vec!["-e".to_string(), code.to_string()]),
|
||||
"bash" => ("bash".to_string(), vec!["-c".to_string(), code.to_string()]),
|
||||
"rust" => {
|
||||
let mut result =
|
||||
ToolResult::error("Rust execution is not yet supported in the sandbox");
|
||||
result.duration = start.elapsed();
|
||||
return Ok(result);
|
||||
}
|
||||
other => return Err(anyhow!("Unsupported language: {}", other)),
|
||||
};
|
||||
|
||||
let sandbox_config = SandboxConfig {
|
||||
allow_network: false,
|
||||
timeout_seconds: timeout,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sandbox_result = tokio::task::spawn_blocking(move || -> Result<_> {
|
||||
let sandbox = SandboxedProcess::new(sandbox_config)?;
|
||||
let arg_refs: Vec<&str> = command_args.iter().map(|s| s.as_str()).collect();
|
||||
sandbox.execute(&command, &arg_refs)
|
||||
})
|
||||
.await
|
||||
.context("Sandbox execution task failed")??;
|
||||
|
||||
let mut result = if sandbox_result.exit_code == 0 {
|
||||
ToolResult::success(json!({
|
||||
"stdout": sandbox_result.stdout,
|
||||
"stderr": sandbox_result.stderr,
|
||||
"exit_code": sandbox_result.exit_code,
|
||||
"timed_out": sandbox_result.was_timeout,
|
||||
}))
|
||||
} else {
|
||||
let error_msg = if sandbox_result.was_timeout {
|
||||
format!(
|
||||
"Execution timed out after {} seconds (exit code {}): {}",
|
||||
timeout, sandbox_result.exit_code, sandbox_result.stderr
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Execution failed with status {}: {}",
|
||||
sandbox_result.exit_code, sandbox_result.stderr
|
||||
)
|
||||
};
|
||||
let mut err_result = ToolResult::error(&error_msg);
|
||||
err_result.output = json!({
|
||||
"stdout": sandbox_result.stdout,
|
||||
"stderr": sandbox_result.stderr,
|
||||
"exit_code": sandbox_result.exit_code,
|
||||
"timed_out": sandbox_result.was_timeout,
|
||||
});
|
||||
err_result
|
||||
};
|
||||
|
||||
result.duration = start.elapsed();
|
||||
result
|
||||
.metadata
|
||||
.insert("language".to_string(), language.to_string());
|
||||
result
|
||||
.metadata
|
||||
.insert("timeout_seconds".to_string(), timeout.to_string());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
53
crates/owlen-core/src/tools/mod.rs
Normal file
53
crates/owlen-core/src/tools/mod.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
pub mod code_exec;
|
||||
pub mod registry;
|
||||
pub mod web_search;
|
||||
pub mod web_search_detailed;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Tool: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn description(&self) -> &'static str;
|
||||
fn schema(&self) -> Value;
|
||||
fn requires_network(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn requires_filesystem(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> Result<ToolResult>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolResult {
|
||||
pub success: bool,
|
||||
pub output: Value,
|
||||
pub duration: std::time::Duration,
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
pub fn success(output: Value) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
output,
|
||||
duration: std::time::Duration::from_millis(0),
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(message: &str) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
output: serde_json::json!({ "error": message }),
|
||||
duration: std::time::Duration::from_millis(0),
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
53
crates/owlen-core/src/tools/registry.rs
Normal file
53
crates/owlen-core/src/tools/registry.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::Tool;
|
||||
|
||||
pub struct ToolRegistry {
|
||||
tools: HashMap<String, Arc<dyn Tool>>,
|
||||
}
|
||||
|
||||
impl Default for ToolRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register<T>(&mut self, tool: T)
|
||||
where
|
||||
T: Tool + 'static,
|
||||
{
|
||||
let tool: Arc<dyn Tool> = Arc::new(tool);
|
||||
let name = tool.name().to_string();
|
||||
self.tools.insert(name, tool);
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
|
||||
self.tools.get(name).cloned()
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<Arc<dyn Tool>> {
|
||||
self.tools.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn execute(&self, name: &str, args: Value) -> Result<super::ToolResult> {
|
||||
let tool = self
|
||||
.get(name)
|
||||
.with_context(|| format!("Tool not registered: {}", name))?;
|
||||
tool.execute(args).await
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> Vec<String> {
|
||||
self.tools.keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
153
crates/owlen-core/src/tools/web_search.rs
Normal file
153
crates/owlen-core/src/tools/web_search.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{Tool, ToolResult};
|
||||
use crate::consent::ConsentManager;
|
||||
use crate::credentials::CredentialManager;
|
||||
use crate::encryption::VaultHandle;
|
||||
|
||||
pub struct WebSearchTool {
|
||||
consent_manager: Arc<Mutex<ConsentManager>>,
|
||||
_credential_manager: Option<Arc<CredentialManager>>,
|
||||
browser: duckduckgo::browser::Browser,
|
||||
}
|
||||
|
||||
impl WebSearchTool {
|
||||
pub fn new(
|
||||
consent_manager: Arc<Mutex<ConsentManager>>,
|
||||
credential_manager: Option<Arc<CredentialManager>>,
|
||||
_vault: Option<Arc<Mutex<VaultHandle>>>,
|
||||
) -> Self {
|
||||
// Create a reqwest client compatible with duckduckgo crate (v0.11)
|
||||
let client = reqwest_011::Client::new();
|
||||
let browser = duckduckgo::browser::Browser::new(client);
|
||||
|
||||
Self {
|
||||
consent_manager,
|
||||
_credential_manager: credential_manager,
|
||||
browser,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WebSearchTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"web_search"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Search the web for information using DuckDuckGo API"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500,
|
||||
"description": "Search query"
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"default": 5,
|
||||
"description": "Maximum number of results"
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn requires_network(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> Result<ToolResult> {
|
||||
let start = Instant::now();
|
||||
|
||||
// Check if consent has been granted (non-blocking check)
|
||||
// Consent should have been granted via TUI dialog before tool execution
|
||||
{
|
||||
let consent = self
|
||||
.consent_manager
|
||||
.lock()
|
||||
.expect("Consent manager mutex poisoned");
|
||||
|
||||
if !consent.has_consent(self.name()) {
|
||||
return Ok(ToolResult::error(
|
||||
"Consent not granted for web search. This should have been handled by the TUI.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let query = args
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.context("Missing query parameter")?;
|
||||
let max_results = args.get("max_results").and_then(Value::as_u64).unwrap_or(5) as usize;
|
||||
|
||||
let user_agent = duckduckgo::user_agents::get("firefox").unwrap_or(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0",
|
||||
);
|
||||
|
||||
// Detect if this is a news query - use news endpoint for better snippets
|
||||
let is_news_query = query.to_lowercase().contains("news")
|
||||
|| query.to_lowercase().contains("latest")
|
||||
|| query.to_lowercase().contains("today")
|
||||
|| query.to_lowercase().contains("recent");
|
||||
|
||||
let mut formatted_results = Vec::new();
|
||||
|
||||
if is_news_query {
|
||||
// Use news endpoint which returns excerpts/snippets
|
||||
let news_results = self
|
||||
.browser
|
||||
.news(query, "wt-wt", false, Some(max_results), user_agent)
|
||||
.await
|
||||
.context("DuckDuckGo news search failed")?;
|
||||
|
||||
for result in news_results {
|
||||
formatted_results.push(json!({
|
||||
"title": result.title,
|
||||
"url": result.url,
|
||||
"snippet": result.body, // news has body/excerpt
|
||||
"source": result.source,
|
||||
"date": result.date
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// Use lite search for general queries (fast but no snippets)
|
||||
let search_results = self
|
||||
.browser
|
||||
.lite_search(query, "wt-wt", Some(max_results), user_agent)
|
||||
.await
|
||||
.context("DuckDuckGo search failed")?;
|
||||
|
||||
for result in search_results {
|
||||
formatted_results.push(json!({
|
||||
"title": result.title,
|
||||
"url": result.url,
|
||||
"snippet": result.snippet
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = ToolResult::success(json!({
|
||||
"query": query,
|
||||
"results": formatted_results,
|
||||
"total_found": formatted_results.len()
|
||||
}));
|
||||
result.duration = start.elapsed();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
130
crates/owlen-core/src/tools/web_search_detailed.rs
Normal file
130
crates/owlen-core/src/tools/web_search_detailed.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{Tool, ToolResult};
|
||||
use crate::consent::ConsentManager;
|
||||
use crate::credentials::CredentialManager;
|
||||
use crate::encryption::VaultHandle;
|
||||
|
||||
pub struct WebSearchDetailedTool {
|
||||
consent_manager: Arc<Mutex<ConsentManager>>,
|
||||
_credential_manager: Option<Arc<CredentialManager>>,
|
||||
browser: duckduckgo::browser::Browser,
|
||||
}
|
||||
|
||||
impl WebSearchDetailedTool {
|
||||
pub fn new(
|
||||
consent_manager: Arc<Mutex<ConsentManager>>,
|
||||
credential_manager: Option<Arc<CredentialManager>>,
|
||||
_vault: Option<Arc<Mutex<VaultHandle>>>,
|
||||
) -> Self {
|
||||
// Create a reqwest client compatible with duckduckgo crate (v0.11)
|
||||
let client = reqwest_011::Client::new();
|
||||
let browser = duckduckgo::browser::Browser::new(client);
|
||||
|
||||
Self {
|
||||
consent_manager,
|
||||
_credential_manager: credential_manager,
|
||||
browser,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WebSearchDetailedTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"web_search_detailed"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Search for recent articles and web content with detailed snippets and descriptions. \
|
||||
Returns results with publication dates, sources, and full text excerpts. \
|
||||
Best for finding recent information, articles, and detailed context about topics."
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500,
|
||||
"description": "Search query"
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"default": 5,
|
||||
"description": "Maximum number of results"
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn requires_network(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> Result<ToolResult> {
|
||||
let start = Instant::now();
|
||||
|
||||
// Check if consent has been granted (non-blocking check)
|
||||
// Consent should have been granted via TUI dialog before tool execution
|
||||
{
|
||||
let consent = self
|
||||
.consent_manager
|
||||
.lock()
|
||||
.expect("Consent manager mutex poisoned");
|
||||
|
||||
if !consent.has_consent(self.name()) {
|
||||
return Ok(ToolResult::error("Consent not granted for detailed web search. This should have been handled by the TUI."));
|
||||
}
|
||||
}
|
||||
|
||||
let query = args
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.context("Missing query parameter")?;
|
||||
let max_results = args.get("max_results").and_then(Value::as_u64).unwrap_or(5) as usize;
|
||||
|
||||
let user_agent = duckduckgo::user_agents::get("firefox").unwrap_or(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:91.0) Gecko/20100101 Firefox/91.0",
|
||||
);
|
||||
|
||||
// Use news endpoint which provides detailed results with full snippets
|
||||
// Even for non-news queries, this often returns recent articles and content with good descriptions
|
||||
let news_results = self
|
||||
.browser
|
||||
.news(query, "wt-wt", false, Some(max_results), user_agent)
|
||||
.await
|
||||
.context("DuckDuckGo detailed search failed")?;
|
||||
|
||||
let mut formatted_results = Vec::new();
|
||||
for result in news_results {
|
||||
formatted_results.push(json!({
|
||||
"title": result.title,
|
||||
"url": result.url,
|
||||
"snippet": result.body, // news endpoint includes full excerpts
|
||||
"source": result.source,
|
||||
"date": result.date
|
||||
}));
|
||||
}
|
||||
|
||||
let mut result = ToolResult::success(json!({
|
||||
"query": query,
|
||||
"results": formatted_results,
|
||||
"total_found": formatted_results.len()
|
||||
}));
|
||||
result.duration = start.elapsed();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ pub struct Message {
|
||||
pub metadata: HashMap<String, serde_json::Value>,
|
||||
/// Timestamp when the message was created
|
||||
pub timestamp: std::time::SystemTime,
|
||||
/// Tool calls requested by the assistant
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
/// Role of a message sender
|
||||
@@ -30,6 +33,19 @@ pub enum Role {
|
||||
Assistant,
|
||||
/// System message (prompts, context, etc.)
|
||||
System,
|
||||
/// Tool response message
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// A tool call requested by the assistant
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ToolCall {
|
||||
/// Unique identifier for this tool call
|
||||
pub id: String,
|
||||
/// Name of the tool to call
|
||||
pub name: String,
|
||||
/// Arguments for the tool (JSON object)
|
||||
pub arguments: serde_json::Value,
|
||||
}
|
||||
|
||||
impl fmt::Display for Role {
|
||||
@@ -38,6 +54,7 @@ impl fmt::Display for Role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
Role::Tool => "tool",
|
||||
};
|
||||
f.write_str(label)
|
||||
}
|
||||
@@ -50,6 +67,9 @@ pub struct Conversation {
|
||||
pub id: Uuid,
|
||||
/// Optional name/title for the conversation
|
||||
pub name: Option<String>,
|
||||
/// Optional AI-generated description of the conversation
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Messages in chronological order
|
||||
pub messages: Vec<Message>,
|
||||
/// Model used for this conversation
|
||||
@@ -69,6 +89,9 @@ pub struct ChatRequest {
|
||||
pub messages: Vec<Message>,
|
||||
/// Optional parameters for the request
|
||||
pub parameters: ChatParameters,
|
||||
/// Optional tools available for the model to use
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<crate::mcp::McpToolDescriptor>>,
|
||||
}
|
||||
|
||||
/// Parameters for chat completion
|
||||
@@ -130,6 +153,9 @@ pub struct ModelInfo {
|
||||
pub context_window: Option<u32>,
|
||||
/// Additional capabilities
|
||||
pub capabilities: Vec<String>,
|
||||
/// Whether this model supports tool/function calling
|
||||
#[serde(default)]
|
||||
pub supports_tools: bool,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
@@ -141,6 +167,7 @@ impl Message {
|
||||
content,
|
||||
metadata: HashMap::new(),
|
||||
timestamp: std::time::SystemTime::now(),
|
||||
tool_calls: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +185,24 @@ impl Message {
|
||||
pub fn system(content: String) -> Self {
|
||||
Self::new(Role::System, content)
|
||||
}
|
||||
|
||||
/// Create a tool response message
|
||||
pub fn tool(tool_call_id: String, content: String) -> Self {
|
||||
let mut msg = Self::new(Role::Tool, content);
|
||||
msg.metadata.insert(
|
||||
"tool_call_id".to_string(),
|
||||
serde_json::Value::String(tool_call_id),
|
||||
);
|
||||
msg
|
||||
}
|
||||
|
||||
/// Check if this message has tool calls
|
||||
pub fn has_tool_calls(&self) -> bool {
|
||||
self.tool_calls
|
||||
.as_ref()
|
||||
.map(|tc| !tc.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
@@ -167,6 +212,7 @@ impl Conversation {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
name: None,
|
||||
description: None,
|
||||
messages: Vec::new(),
|
||||
model,
|
||||
created_at: now,
|
||||
|
||||
@@ -22,6 +22,8 @@ pub enum InputMode {
|
||||
Help,
|
||||
Visual,
|
||||
Command,
|
||||
SessionBrowser,
|
||||
ThemeBrowser,
|
||||
}
|
||||
|
||||
impl fmt::Display for InputMode {
|
||||
@@ -34,6 +36,8 @@ impl fmt::Display for InputMode {
|
||||
InputMode::Help => "Help",
|
||||
InputMode::Visual => "Visual",
|
||||
InputMode::Command => "Command",
|
||||
InputMode::SessionBrowser => "Sessions",
|
||||
InputMode::ThemeBrowser => "Themes",
|
||||
};
|
||||
f.write_str(label)
|
||||
}
|
||||
@@ -353,8 +357,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_auto_scroll() {
|
||||
let mut scroll = AutoScroll::default();
|
||||
scroll.content_len = 100;
|
||||
let mut scroll = AutoScroll {
|
||||
content_len: 100,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Test on_viewport with stick_to_bottom
|
||||
scroll.on_viewport(10);
|
||||
|
||||
108
crates/owlen-core/src/validation.rs
Normal file
108
crates/owlen-core/src/validation.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use jsonschema::{JSONSchema, ValidationError};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct SchemaValidator {
|
||||
schemas: HashMap<String, JSONSchema>,
|
||||
}
|
||||
|
||||
impl Default for SchemaValidator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SchemaValidator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
schemas: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_schema(&mut self, tool_name: &str, schema: Value) -> Result<()> {
|
||||
let compiled = JSONSchema::compile(&schema)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid schema for {}: {}", tool_name, e))?;
|
||||
|
||||
self.schemas.insert(tool_name.to_string(), compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate(&self, tool_name: &str, input: &Value) -> Result<()> {
|
||||
let schema = self
|
||||
.schemas
|
||||
.get(tool_name)
|
||||
.with_context(|| format!("No schema registered for tool: {}", tool_name))?;
|
||||
|
||||
if let Err(errors) = schema.validate(input) {
|
||||
let error_messages: Vec<String> = errors.map(format_validation_error).collect();
|
||||
|
||||
return Err(anyhow::anyhow!(
|
||||
"Input validation failed for {}: {}",
|
||||
tool_name,
|
||||
error_messages.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn format_validation_error(error: ValidationError) -> String {
|
||||
format!("Validation error at {}: {}", error.instance_path, error)
|
||||
}
|
||||
|
||||
pub fn get_builtin_schemas() -> HashMap<String, Value> {
|
||||
let mut schemas = HashMap::new();
|
||||
|
||||
schemas.insert(
|
||||
"web_search".to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"default": 5
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
);
|
||||
|
||||
schemas.insert(
|
||||
"code_exec".to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": ["python", "javascript", "bash", "rust"]
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 10000
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 300,
|
||||
"default": 30
|
||||
}
|
||||
},
|
||||
"required": ["language", "code"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
);
|
||||
|
||||
schemas
|
||||
}
|
||||
5
crates/owlen-gemini/README.md
Normal file
5
crates/owlen-gemini/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Owlen Gemini
|
||||
|
||||
This crate is a placeholder for a future `owlen-core::Provider` implementation for the Google Gemini API.
|
||||
|
||||
This provider is not yet implemented. Contributions are welcome!
|
||||
9
crates/owlen-ollama/README.md
Normal file
9
crates/owlen-ollama/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Owlen Ollama
|
||||
|
||||
This crate provides an implementation of the `owlen-core::Provider` trait for the [Ollama](https://ollama.ai) backend.
|
||||
|
||||
It allows Owlen to communicate with a local Ollama instance, sending requests and receiving responses from locally-run large language models. You can also target [Ollama Cloud](https://docs.ollama.com/cloud) by pointing the provider at `https://ollama.com` (or `https://api.ollama.com`) and providing an API key through your Owlen configuration (or the `OLLAMA_API_KEY` / `OLLAMA_CLOUD_API_KEY` environment variables). The client automatically adds the required Bearer authorization header when a key is supplied, accepts either host without rewriting, and expands inline environment references like `$OLLAMA_API_KEY` if you prefer not to check the secret into your config file. The generated configuration now includes both `providers.ollama` and `providers.ollama-cloud` entries—switch between them by updating `general.default_provider`.
|
||||
|
||||
## Configuration
|
||||
|
||||
To use this provider, you need to have Ollama installed and running. The default address is `http://localhost:11434`. You can configure this in your `config.toml` if your Ollama instance is running elsewhere.
|
||||
@@ -5,13 +5,16 @@ use owlen_core::{
|
||||
config::GeneralSettings,
|
||||
model::ModelManager,
|
||||
provider::{ChatStream, Provider, ProviderConfig},
|
||||
types::{ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage},
|
||||
types::{
|
||||
ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage, ToolCall,
|
||||
},
|
||||
Result,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use reqwest::{header, Client, Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -20,26 +23,195 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
||||
const DEFAULT_MODEL_CACHE_TTL_SECS: u64 = 60;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum OllamaMode {
|
||||
Local,
|
||||
Cloud,
|
||||
}
|
||||
|
||||
impl OllamaMode {
|
||||
fn from_provider_type(provider_type: &str) -> Self {
|
||||
if provider_type.eq_ignore_ascii_case("ollama-cloud") {
|
||||
Self::Cloud
|
||||
} else {
|
||||
Self::Local
|
||||
}
|
||||
}
|
||||
|
||||
fn default_base_url(self) -> &'static str {
|
||||
match self {
|
||||
Self::Local => "http://localhost:11434",
|
||||
Self::Cloud => "https://ollama.com",
|
||||
}
|
||||
}
|
||||
|
||||
fn default_scheme(self) -> &'static str {
|
||||
match self {
|
||||
Self::Local => "http",
|
||||
Self::Cloud => "https",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ollama_host(host: &str) -> bool {
|
||||
host.eq_ignore_ascii_case("ollama.com")
|
||||
|| host.eq_ignore_ascii_case("www.ollama.com")
|
||||
|| host.eq_ignore_ascii_case("api.ollama.com")
|
||||
|| host.ends_with(".ollama.com")
|
||||
}
|
||||
|
||||
fn normalize_base_url(
|
||||
input: Option<&str>,
|
||||
mode_hint: OllamaMode,
|
||||
) -> std::result::Result<String, String> {
|
||||
let mut candidate = input
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| mode_hint.default_base_url().to_string());
|
||||
|
||||
if !candidate.contains("://") {
|
||||
candidate = format!("{}://{}", mode_hint.default_scheme(), candidate);
|
||||
}
|
||||
|
||||
let mut url =
|
||||
Url::parse(&candidate).map_err(|err| format!("Invalid base_url '{candidate}': {err}"))?;
|
||||
|
||||
let mut is_cloud = matches!(mode_hint, OllamaMode::Cloud);
|
||||
|
||||
if let Some(host) = url.host_str() {
|
||||
if is_ollama_host(host) {
|
||||
is_cloud = true;
|
||||
}
|
||||
}
|
||||
|
||||
if is_cloud {
|
||||
if url.scheme() != "https" {
|
||||
url.set_scheme("https")
|
||||
.map_err(|_| "Ollama Cloud requires an https URL".to_string())?;
|
||||
}
|
||||
|
||||
match url.host_str() {
|
||||
Some(host) => {
|
||||
if host.eq_ignore_ascii_case("www.ollama.com") {
|
||||
url.set_host(Some("ollama.com"))
|
||||
.map_err(|_| "Failed to normalize Ollama Cloud host".to_string())?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return Err("Ollama Cloud base_url must include a hostname".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing slash and discard query/fragment segments
|
||||
let current_path = url.path().to_string();
|
||||
let trimmed_path = current_path.trim_end_matches('/');
|
||||
if trimmed_path.is_empty() {
|
||||
url.set_path("");
|
||||
} else {
|
||||
url.set_path(trimmed_path);
|
||||
}
|
||||
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
|
||||
Ok(url.to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn build_api_endpoint(base_url: &str, endpoint: &str) -> String {
|
||||
let trimmed_base = base_url.trim_end_matches('/');
|
||||
let trimmed_endpoint = endpoint.trim_start_matches('/');
|
||||
|
||||
if trimmed_base.ends_with("/api") {
|
||||
format!("{trimmed_base}/{trimmed_endpoint}")
|
||||
} else {
|
||||
format!("{trimmed_base}/api/{trimmed_endpoint}")
|
||||
}
|
||||
}
|
||||
|
||||
fn env_var_non_empty(name: &str) -> Option<String> {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn resolve_api_key(configured: Option<String>) -> Option<String> {
|
||||
let raw = configured?.trim().to_string();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(variable) = raw
|
||||
.strip_prefix("${")
|
||||
.and_then(|value| value.strip_suffix('}'))
|
||||
.or_else(|| raw.strip_prefix('$'))
|
||||
{
|
||||
let var_name = variable.trim();
|
||||
if var_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return env_var_non_empty(var_name);
|
||||
}
|
||||
|
||||
Some(raw)
|
||||
}
|
||||
|
||||
fn debug_requests_enabled() -> bool {
|
||||
std::env::var("OWLEN_DEBUG_OLLAMA")
|
||||
.ok()
|
||||
.map(|value| {
|
||||
matches!(
|
||||
value.trim(),
|
||||
"1" | "true" | "TRUE" | "True" | "yes" | "YES" | "Yes"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn mask_token(token: &str) -> String {
|
||||
if token.len() <= 8 {
|
||||
return "***".to_string();
|
||||
}
|
||||
|
||||
let head = &token[..4];
|
||||
let tail = &token[token.len() - 4..];
|
||||
format!("{head}***{tail}")
|
||||
}
|
||||
|
||||
fn mask_authorization(value: &str) -> String {
|
||||
if let Some(token) = value.strip_prefix("Bearer ") {
|
||||
format!("Bearer {}", mask_token(token))
|
||||
} else {
|
||||
"***".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ollama provider implementation with enhanced configuration and caching
|
||||
#[derive(Debug)]
|
||||
pub struct OllamaProvider {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
model_manager: ModelManager,
|
||||
}
|
||||
|
||||
/// Options for configuring the Ollama provider
|
||||
pub struct OllamaOptions {
|
||||
pub base_url: String,
|
||||
pub request_timeout: Duration,
|
||||
pub model_cache_ttl: Duration,
|
||||
pub(crate) struct OllamaOptions {
|
||||
base_url: String,
|
||||
request_timeout: Duration,
|
||||
model_cache_ttl: Duration,
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl OllamaOptions {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
pub(crate) fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
model_cache_ttl: Duration::from_secs(DEFAULT_MODEL_CACHE_TTL_SECS),
|
||||
api_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +226,20 @@ impl OllamaOptions {
|
||||
struct OllamaMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<OllamaToolCall>>,
|
||||
}
|
||||
|
||||
/// Ollama tool call format
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct OllamaToolCall {
|
||||
function: OllamaToolCallFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct OllamaToolCallFunction {
|
||||
name: String,
|
||||
arguments: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Ollama chat request format
|
||||
@@ -62,10 +248,27 @@ struct OllamaChatRequest {
|
||||
model: String,
|
||||
messages: Vec<OllamaMessage>,
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<OllamaTool>>,
|
||||
#[serde(flatten)]
|
||||
options: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Ollama tool definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct OllamaTool {
|
||||
#[serde(rename = "type")]
|
||||
tool_type: String,
|
||||
function: OllamaToolFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct OllamaToolFunction {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Ollama chat response format
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaChatResponse {
|
||||
@@ -107,17 +310,60 @@ struct OllamaModelDetails {
|
||||
impl OllamaProvider {
|
||||
/// Create a new Ollama provider with sensible defaults
|
||||
pub fn new(base_url: impl Into<String>) -> Result<Self> {
|
||||
Self::with_options(OllamaOptions::new(base_url))
|
||||
let mode = OllamaMode::Local;
|
||||
let supplied = base_url.into();
|
||||
let normalized =
|
||||
normalize_base_url(Some(&supplied), mode).map_err(owlen_core::Error::Config)?;
|
||||
|
||||
Self::with_options(OllamaOptions::new(normalized))
|
||||
}
|
||||
|
||||
fn debug_log_request(&self, label: &str, request: &reqwest::Request, body_json: Option<&str>) {
|
||||
if !debug_requests_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
eprintln!("--- OWLEN Ollama request ({label}) ---");
|
||||
eprintln!("{} {}", request.method(), request.url());
|
||||
|
||||
match request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
Some(value) => eprintln!("Authorization: {}", mask_authorization(value)),
|
||||
None => eprintln!("Authorization: <none>"),
|
||||
}
|
||||
|
||||
if let Some(body) = body_json {
|
||||
eprintln!("Body:\n{body}");
|
||||
}
|
||||
|
||||
eprintln!("---------------------------------------");
|
||||
}
|
||||
|
||||
/// Convert MCP tool descriptors to Ollama tool format
|
||||
fn convert_tools_to_ollama(tools: &[owlen_core::mcp::McpToolDescriptor]) -> Vec<OllamaTool> {
|
||||
tools
|
||||
.iter()
|
||||
.map(|tool| OllamaTool {
|
||||
tool_type: "function".to_string(),
|
||||
function: OllamaToolFunction {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Create a provider from configuration settings
|
||||
pub fn from_config(config: &ProviderConfig, general: Option<&GeneralSettings>) -> Result<Self> {
|
||||
let mut options = OllamaOptions::new(
|
||||
config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| "http://localhost:11434".to_string()),
|
||||
);
|
||||
let mode = OllamaMode::from_provider_type(&config.provider_type);
|
||||
let normalized_base_url = normalize_base_url(config.base_url.as_deref(), mode)
|
||||
.map_err(owlen_core::Error::Config)?;
|
||||
|
||||
let mut options = OllamaOptions::new(normalized_base_url);
|
||||
|
||||
if let Some(timeout) = config
|
||||
.extra
|
||||
@@ -135,6 +381,10 @@ impl OllamaProvider {
|
||||
options.model_cache_ttl = Duration::from_secs(cache_ttl.max(5));
|
||||
}
|
||||
|
||||
options.api_key = resolve_api_key(config.api_key.clone())
|
||||
.or_else(|| env_var_non_empty("OLLAMA_API_KEY"))
|
||||
.or_else(|| env_var_non_empty("OLLAMA_CLOUD_API_KEY"));
|
||||
|
||||
if let Some(general) = general {
|
||||
options = options.with_general(general);
|
||||
}
|
||||
@@ -143,16 +393,24 @@ impl OllamaProvider {
|
||||
}
|
||||
|
||||
/// Create a provider from explicit options
|
||||
pub fn with_options(options: OllamaOptions) -> Result<Self> {
|
||||
pub(crate) fn with_options(options: OllamaOptions) -> Result<Self> {
|
||||
let OllamaOptions {
|
||||
base_url,
|
||||
request_timeout,
|
||||
model_cache_ttl,
|
||||
api_key,
|
||||
} = options;
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(options.request_timeout)
|
||||
.timeout(request_timeout)
|
||||
.build()
|
||||
.map_err(|e| owlen_core::Error::Config(format!("Failed to build HTTP client: {e}")))?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url: options.base_url.trim_end_matches('/').to_string(),
|
||||
model_manager: ModelManager::new(options.model_cache_ttl),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
api_key,
|
||||
model_manager: ModelManager::new(model_cache_ttl),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,14 +419,42 @@ impl OllamaProvider {
|
||||
&self.model_manager
|
||||
}
|
||||
|
||||
fn api_url(&self, endpoint: &str) -> String {
|
||||
build_api_endpoint(&self.base_url, endpoint)
|
||||
}
|
||||
|
||||
fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(api_key) = &self.api_key {
|
||||
request.bearer_auth(api_key)
|
||||
} else {
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_message(message: &Message) -> OllamaMessage {
|
||||
let role = match message.role {
|
||||
Role::User => "user".to_string(),
|
||||
Role::Assistant => "assistant".to_string(),
|
||||
Role::System => "system".to_string(),
|
||||
Role::Tool => "tool".to_string(),
|
||||
};
|
||||
|
||||
let tool_calls = message.tool_calls.as_ref().map(|calls| {
|
||||
calls
|
||||
.iter()
|
||||
.map(|tc| OllamaToolCall {
|
||||
function: OllamaToolCallFunction {
|
||||
name: tc.name.clone(),
|
||||
arguments: tc.arguments.clone(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
OllamaMessage {
|
||||
role: match message.role {
|
||||
Role::User => "user".to_string(),
|
||||
Role::Assistant => "assistant".to_string(),
|
||||
Role::System => "system".to_string(),
|
||||
},
|
||||
role,
|
||||
content: message.content.clone(),
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,10 +463,27 @@ impl OllamaProvider {
|
||||
"user" => Role::User,
|
||||
"assistant" => Role::Assistant,
|
||||
"system" => Role::System,
|
||||
"tool" => Role::Tool,
|
||||
_ => Role::Assistant,
|
||||
};
|
||||
|
||||
Message::new(role, message.content.clone())
|
||||
let mut msg = Message::new(role, message.content.clone());
|
||||
|
||||
// Convert tool calls if present
|
||||
if let Some(ollama_tool_calls) = &message.tool_calls {
|
||||
let tool_calls: Vec<ToolCall> = ollama_tool_calls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, tc)| ToolCall {
|
||||
id: format!("call_{}", idx),
|
||||
name: tc.function.name.clone(),
|
||||
arguments: tc.function.arguments.clone(),
|
||||
})
|
||||
.collect();
|
||||
msg.tool_calls = Some(tool_calls);
|
||||
}
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
fn build_options(parameters: ChatParameters) -> HashMap<String, Value> {
|
||||
@@ -202,11 +505,10 @@ impl OllamaProvider {
|
||||
}
|
||||
|
||||
async fn fetch_models(&self) -> Result<Vec<ModelInfo>> {
|
||||
let url = format!("{}/api/tags", self.base_url);
|
||||
let url = self.api_url("tags");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.apply_auth(self.client.get(&url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| owlen_core::Error::Network(format!("Failed to fetch models: {e}")))?;
|
||||
@@ -229,21 +531,51 @@ impl OllamaProvider {
|
||||
let models = ollama_response
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|model| ModelInfo {
|
||||
id: model.name.clone(),
|
||||
name: model.name.clone(),
|
||||
description: model
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|d| d.family.as_ref().map(|f| format!("Ollama {f} model"))),
|
||||
provider: "ollama".to_string(),
|
||||
context_window: None,
|
||||
capabilities: vec!["chat".to_string()],
|
||||
.map(|model| {
|
||||
// Check if model supports tool calling based on known models
|
||||
let supports_tools = Self::check_tool_support(&model.name);
|
||||
|
||||
ModelInfo {
|
||||
id: model.name.clone(),
|
||||
name: model.name.clone(),
|
||||
description: model
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|d| d.family.as_ref().map(|f| format!("Ollama {f} model"))),
|
||||
provider: "ollama".to_string(),
|
||||
context_window: None,
|
||||
capabilities: vec!["chat".to_string()],
|
||||
supports_tools,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// Check if a model supports tool calling based on its name
|
||||
fn check_tool_support(model_name: &str) -> bool {
|
||||
let name_lower = model_name.to_lowercase();
|
||||
|
||||
// Known models with tool calling support
|
||||
let tool_supporting_models = [
|
||||
"qwen",
|
||||
"llama3.1",
|
||||
"llama3.2",
|
||||
"llama3.3",
|
||||
"mistral-nemo",
|
||||
"mistral:7b-instruct",
|
||||
"command-r",
|
||||
"firefunction",
|
||||
"hermes",
|
||||
"nexusraven",
|
||||
"granite-code",
|
||||
];
|
||||
|
||||
tool_supporting_models
|
||||
.iter()
|
||||
.any(|&supported| name_lower.contains(supported))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -263,25 +595,42 @@ impl Provider for OllamaProvider {
|
||||
model,
|
||||
messages,
|
||||
parameters,
|
||||
tools,
|
||||
} = request;
|
||||
|
||||
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
|
||||
|
||||
let options = Self::build_options(parameters);
|
||||
|
||||
let ollama_tools = tools.as_ref().map(|t| Self::convert_tools_to_ollama(t));
|
||||
|
||||
let ollama_request = OllamaChatRequest {
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
tools: ollama_tools,
|
||||
options,
|
||||
};
|
||||
|
||||
let url = format!("{}/api/chat", self.base_url);
|
||||
let url = self.api_url("chat");
|
||||
let debug_body = if debug_requests_enabled() {
|
||||
serde_json::to_string_pretty(&ollama_request).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut request_builder = self.client.post(&url).json(&ollama_request);
|
||||
request_builder = self.apply_auth(request_builder);
|
||||
|
||||
let request = request_builder.build().map_err(|e| {
|
||||
owlen_core::Error::Network(format!("Failed to build chat request: {e}"))
|
||||
})?;
|
||||
|
||||
self.debug_log_request("chat", &request, debug_body.as_deref());
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&ollama_request)
|
||||
.send()
|
||||
.execute(request)
|
||||
.await
|
||||
.map_err(|e| owlen_core::Error::Network(format!("Chat request failed: {e}")))?;
|
||||
|
||||
@@ -339,28 +688,43 @@ impl Provider for OllamaProvider {
|
||||
model,
|
||||
messages,
|
||||
parameters,
|
||||
tools,
|
||||
} = request;
|
||||
|
||||
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
|
||||
|
||||
let options = Self::build_options(parameters);
|
||||
|
||||
let ollama_tools = tools.as_ref().map(|t| Self::convert_tools_to_ollama(t));
|
||||
|
||||
let ollama_request = OllamaChatRequest {
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
tools: ollama_tools,
|
||||
options,
|
||||
};
|
||||
|
||||
let url = format!("{}/api/chat", self.base_url);
|
||||
let url = self.api_url("chat");
|
||||
let debug_body = if debug_requests_enabled() {
|
||||
serde_json::to_string_pretty(&ollama_request).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&ollama_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| owlen_core::Error::Network(format!("Streaming request failed: {e}")))?;
|
||||
let mut request_builder = self.client.post(&url).json(&ollama_request);
|
||||
request_builder = self.apply_auth(request_builder);
|
||||
|
||||
let request = request_builder.build().map_err(|e| {
|
||||
owlen_core::Error::Network(format!("Failed to build streaming request: {e}"))
|
||||
})?;
|
||||
|
||||
self.debug_log_request("chat_stream", &request, debug_body.as_deref());
|
||||
|
||||
let response =
|
||||
self.client.execute(request).await.map_err(|e| {
|
||||
owlen_core::Error::Network(format!("Streaming request failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let code = response.status();
|
||||
@@ -462,11 +826,10 @@ impl Provider for OllamaProvider {
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<()> {
|
||||
let url = format!("{}/api/version", self.base_url);
|
||||
let url = self.api_url("version");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.apply_auth(self.client.get(&url))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| owlen_core::Error::Network(format!("Health check failed: {e}")))?;
|
||||
@@ -528,3 +891,86 @@ async fn parse_error_body(response: reqwest::Response) -> String {
|
||||
Err(_) => "unknown error".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_local_base_url_and_infers_scheme() {
|
||||
let normalized =
|
||||
normalize_base_url(Some("localhost:11434"), OllamaMode::Local).expect("valid URL");
|
||||
assert_eq!(normalized, "http://localhost:11434");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_cloud_base_url_and_host() {
|
||||
let normalized =
|
||||
normalize_base_url(Some("https://ollama.com"), OllamaMode::Cloud).expect("valid URL");
|
||||
assert_eq!(normalized, "https://ollama.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_scheme_for_cloud_hosts() {
|
||||
let normalized =
|
||||
normalize_base_url(Some("ollama.com"), OllamaMode::Cloud).expect("valid URL");
|
||||
assert_eq!(normalized, "https://ollama.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_www_cloud_host() {
|
||||
let normalized = normalize_base_url(Some("https://www.ollama.com"), OllamaMode::Cloud)
|
||||
.expect("valid URL");
|
||||
assert_eq!(normalized, "https://ollama.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_explicit_api_suffix() {
|
||||
let normalized = normalize_base_url(Some("https://api.ollama.com/api"), OllamaMode::Cloud)
|
||||
.expect("valid URL");
|
||||
assert_eq!(normalized, "https://api.ollama.com/api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_api_endpoint_without_duplicate_segments() {
|
||||
let base = "http://localhost:11434";
|
||||
assert_eq!(
|
||||
build_api_endpoint(base, "chat"),
|
||||
"http://localhost:11434/api/chat"
|
||||
);
|
||||
|
||||
let base_with_api = "http://localhost:11434/api";
|
||||
assert_eq!(
|
||||
build_api_endpoint(base_with_api, "chat"),
|
||||
"http://localhost:11434/api/chat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_prefers_literal_value() {
|
||||
assert_eq!(
|
||||
resolve_api_key(Some("direct-key".into())),
|
||||
Some("direct-key".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_expands_braced_env_reference() {
|
||||
std::env::set_var("OWLEN_TEST_KEY", "super-secret");
|
||||
assert_eq!(
|
||||
resolve_api_key(Some("${OWLEN_TEST_KEY}".into())),
|
||||
Some("super-secret".into())
|
||||
);
|
||||
std::env::remove_var("OWLEN_TEST_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_expands_unbraced_env_reference() {
|
||||
std::env::set_var("OWLEN_TEST_KEY", "another-secret");
|
||||
assert_eq!(
|
||||
resolve_api_key(Some("$OWLEN_TEST_KEY".into())),
|
||||
Some("another-secret".into())
|
||||
);
|
||||
std::env::remove_var("OWLEN_TEST_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
5
crates/owlen-openai/README.md
Normal file
5
crates/owlen-openai/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Owlen OpenAI
|
||||
|
||||
This crate is a placeholder for a future `owlen-core::Provider` implementation for the OpenAI API.
|
||||
|
||||
This provider is not yet implemented. Contributions are welcome!
|
||||
@@ -10,6 +10,7 @@ description = "Terminal User Interface for OWLEN LLM client"
|
||||
|
||||
[dependencies]
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
owlen-ollama = { path = "../owlen-ollama" }
|
||||
|
||||
# TUI framework
|
||||
ratatui = { workspace = true }
|
||||
@@ -26,6 +27,7 @@ futures-util = { workspace = true }
|
||||
# Utilities
|
||||
anyhow = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
12
crates/owlen-tui/README.md
Normal file
12
crates/owlen-tui/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Owlen TUI
|
||||
|
||||
This crate contains all the logic for the terminal user interface (TUI) of Owlen.
|
||||
|
||||
It is built using the excellent [`ratatui`](https://ratatui.rs) library and is responsible for rendering the chat interface, handling user input, and managing the application state.
|
||||
|
||||
## Features
|
||||
|
||||
- **Chat View**: A scrollable view of the conversation history.
|
||||
- **Input Box**: A text input area for composing messages.
|
||||
- **Model Selection**: An interface for switching between different models.
|
||||
- **Event Handling**: A system for managing keyboard events and asynchronous operations.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,14 @@ pub struct CodeApp {
|
||||
}
|
||||
|
||||
impl CodeApp {
|
||||
pub fn new(mut controller: SessionController) -> (Self, mpsc::UnboundedReceiver<SessionEvent>) {
|
||||
pub async fn new(
|
||||
mut controller: SessionController,
|
||||
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
|
||||
controller
|
||||
.conversation_mut()
|
||||
.push_system_message(DEFAULT_SYSTEM_PROMPT.to_string());
|
||||
let (inner, rx) = ChatApp::new(controller);
|
||||
(Self { inner }, rx)
|
||||
let (inner, rx) = ChatApp::new(controller).await?;
|
||||
Ok((Self { inner }, rx))
|
||||
}
|
||||
|
||||
pub async fn handle_event(&mut self, event: Event) -> Result<AppState> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pub use owlen_core::config::{
|
||||
default_config_path, ensure_ollama_config, session_timeout, Config, GeneralSettings,
|
||||
InputSettings, StorageSettings, UiSettings, DEFAULT_CONFIG_PATH,
|
||||
default_config_path, ensure_ollama_config, ensure_provider_config, session_timeout, Config,
|
||||
GeneralSettings, InputSettings, StorageSettings, UiSettings, DEFAULT_CONFIG_PATH,
|
||||
};
|
||||
|
||||
/// Attempt to load configuration from default location
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
//! # Owlen TUI
|
||||
//!
|
||||
//! This crate contains all the logic for the terminal user interface (TUI) of Owlen.
|
||||
//!
|
||||
//! It is built using the excellent [`ratatui`](https://ratatui.rs) library and is responsible for
|
||||
//! rendering the chat interface, handling user input, and managing the application state.
|
||||
//!
|
||||
//! ## Modules
|
||||
//! - `chat_app`: The main application logic for the chat client.
|
||||
//! - `code_app`: The main application logic for the experimental code client.
|
||||
//! - `config`: TUI-specific configuration.
|
||||
//! - `events`: Event handling for user input and other asynchronous actions.
|
||||
//! - `ui`: The rendering logic for all TUI components.
|
||||
|
||||
pub mod chat_app;
|
||||
pub mod code_app;
|
||||
pub mod config;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
71
docs/architecture.md
Normal file
71
docs/architecture.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Owlen Architecture
|
||||
|
||||
This document provides a high-level overview of the Owlen architecture. Its purpose is to help developers understand how the different parts of the application fit together.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
The architecture is designed to be modular and extensible, centered around a few key concepts:
|
||||
|
||||
- **Providers**: Connect to various LLM APIs (Ollama, OpenAI, etc.).
|
||||
- **Session**: Manages the conversation history and state.
|
||||
- **TUI**: The terminal user interface, built with `ratatui`.
|
||||
- **Events**: A system for handling user input and other events.
|
||||
|
||||
## Component Interaction
|
||||
|
||||
A simplified diagram of how components interact:
|
||||
|
||||
```
|
||||
[User Input] -> [Event Loop] -> [Session Controller] -> [Provider]
|
||||
^ |
|
||||
| v
|
||||
[TUI Renderer] <------------------------------------ [API Response]
|
||||
```
|
||||
|
||||
1. **User Input**: The user interacts with the TUI, generating events (e.g., key presses).
|
||||
2. **Event Loop**: The main event loop in `owlen-tui` captures these events.
|
||||
3. **Session Controller**: The event is processed, and if it's a prompt, the session controller sends a request to the current provider.
|
||||
4. **Provider**: The provider formats the request for the specific LLM API and sends it.
|
||||
5. **API Response**: The LLM API returns a response.
|
||||
6. **TUI Renderer**: The response is processed, the session state is updated, and the TUI is re-rendered to display the new information.
|
||||
|
||||
## Crate Breakdown
|
||||
|
||||
- `owlen-core`: Defines the core traits and data structures, like `Provider` and `Session`.
|
||||
- `owlen-tui`: Contains all the logic for the terminal user interface, including event handling and rendering.
|
||||
- `owlen-cli`: The command-line entry point, responsible for parsing arguments and starting the TUI.
|
||||
- `owlen-ollama` / `owlen-openai` / etc.: Implementations of the `Provider` trait for specific services.
|
||||
|
||||
## Session Management
|
||||
|
||||
The session management system is responsible for tracking the state of a conversation. The two main structs are:
|
||||
|
||||
- **`Conversation`**: Found in `owlen-core`, this struct holds the messages of a single conversation, the model being used, and other metadata. It is a simple data container.
|
||||
- **`SessionController`**: This is the high-level controller that manages the active conversation. It handles:
|
||||
- Storing and retrieving conversation history via the `ConversationManager`.
|
||||
- Managing the context that is sent to the LLM provider.
|
||||
- Switching between different models.
|
||||
- Sending requests to the provider and handling the responses (both streaming and complete).
|
||||
|
||||
When a user sends a message, the `SessionController` adds the message to the current `Conversation`, sends the updated message list to the `Provider`, and then adds the provider's response to the `Conversation`.
|
||||
|
||||
## Event Flow
|
||||
|
||||
The event flow is managed by the `EventHandler` in `owlen-tui`. It operates in a loop, waiting for events and dispatching them to the active application (`ChatApp` or `CodeApp`).
|
||||
|
||||
1. **Event Source**: Events are primarily generated by `crossterm` from user keyboard input. Asynchronous events, like responses from a `Provider`, are also fed into the event system via a `tokio::mpsc` channel.
|
||||
2. **`EventHandler::next()`**: The main application loop calls this method to wait for the next event.
|
||||
3. **Event Enum**: Events are defined in the `owlen_tui::events::Event` enum. This includes `Key` events, `Tick` events (for UI updates), and `Message` events (for async provider data).
|
||||
4. **Dispatch**: The application's `run` method matches on the `Event` type and calls the appropriate handler function (e.g., `dispatch_key_event`).
|
||||
5. **State Update**: The handler function updates the application state based on the event. For example, a key press might change the `InputMode` or modify the text in the input buffer.
|
||||
6. **Re-render**: After the state is updated, the UI is re-rendered to reflect the changes.
|
||||
|
||||
## TUI Rendering Pipeline
|
||||
|
||||
The TUI is rendered on each iteration of the main application loop in `owlen-tui`. The process is as follows:
|
||||
|
||||
1. **`tui.draw()`**: The main loop calls this method, passing the current application state.
|
||||
2. **`Terminal::draw()`**: This method, from `ratatui`, takes a closure that receives a `Frame`.
|
||||
3. **UI Composition**: Inside the closure, the UI is built by composing `ratatui` widgets. The root UI is defined in `owlen_tui::ui::render`, which builds the main layout and calls other functions to render specific components (like the chat panel, input box, etc.).
|
||||
4. **State-Driven Rendering**: Each rendering function takes the current application state as an argument. It uses this state to decide what and how to render. For example, the border color of a panel might change if it is focused.
|
||||
5. **Buffer and Diff**: `ratatui` does not draw directly to the terminal. Instead, it renders the widgets to an in-memory buffer. It then compares this buffer to the previous buffer and only sends the necessary changes to the terminal. This is highly efficient and prevents flickering.
|
||||
136
docs/configuration.md
Normal file
136
docs/configuration.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Owlen Configuration
|
||||
|
||||
Owlen uses a TOML file for configuration, allowing you to customize its behavior to your liking. This document details all the available options.
|
||||
|
||||
## File Location
|
||||
|
||||
By default, Owlen looks for its configuration file at `~/.config/owlen/config.toml`.
|
||||
|
||||
A default configuration file is created on the first run if one doesn't exist.
|
||||
|
||||
## Configuration Precedence
|
||||
|
||||
Configuration values are resolved in the following order:
|
||||
|
||||
1. **Defaults**: The application has hard-coded default values for all settings.
|
||||
2. **Configuration File**: Any values set in `config.toml` will override the defaults.
|
||||
3. **Command-Line Arguments / In-App Changes**: Any settings changed during runtime (e.g., via the `:theme` or `:model` commands) will override the configuration file for the current session. Some of these changes (like theme and model) are automatically saved back to the configuration file.
|
||||
|
||||
---
|
||||
|
||||
## General Settings (`[general]`)
|
||||
|
||||
These settings control the core behavior of the application.
|
||||
|
||||
- `default_provider` (string, default: `"ollama"`)
|
||||
The name of the provider to use by default.
|
||||
|
||||
- `default_model` (string, optional, default: `"llama3.2:latest"`)
|
||||
The default model to use for new conversations.
|
||||
|
||||
- `enable_streaming` (boolean, default: `true`)
|
||||
Whether to stream responses from the provider by default.
|
||||
|
||||
- `project_context_file` (string, optional, default: `"OWLEN.md"`)
|
||||
Path to a file whose content will be automatically injected as a system prompt. This is useful for providing project-specific context.
|
||||
|
||||
- `model_cache_ttl_secs` (integer, default: `60`)
|
||||
Time-to-live in seconds for the cached list of available models.
|
||||
|
||||
## UI Settings (`[ui]`)
|
||||
|
||||
These settings customize the look and feel of the terminal interface.
|
||||
|
||||
- `theme` (string, default: `"default_dark"`)
|
||||
The name of the theme to use. See the [Theming Guide](https://github.com/Owlibou/owlen/blob/main/themes/README.md) for available themes.
|
||||
|
||||
- `word_wrap` (boolean, default: `true`)
|
||||
Whether to wrap long lines in the chat view.
|
||||
|
||||
- `max_history_lines` (integer, default: `2000`)
|
||||
The maximum number of lines to keep in the scrollback buffer for the chat history.
|
||||
|
||||
- `show_role_labels` (boolean, default: `true`)
|
||||
Whether to show the `user` and `bot` role labels next to messages.
|
||||
|
||||
- `wrap_column` (integer, default: `100`)
|
||||
The column at which to wrap text if `word_wrap` is enabled.
|
||||
|
||||
## Storage Settings (`[storage]`)
|
||||
|
||||
These settings control how conversations are saved and loaded.
|
||||
|
||||
- `conversation_dir` (string, optional, default: platform-specific)
|
||||
The directory where conversation sessions are saved. If not set, a default directory is used:
|
||||
- **Linux**: `~/.local/share/owlen/sessions`
|
||||
- **Windows**: `%APPDATA%\owlen\sessions`
|
||||
- **macOS**: `~/Library/Application Support/owlen/sessions`
|
||||
|
||||
- `auto_save_sessions` (boolean, default: `true`)
|
||||
Whether to automatically save the session when the application exits.
|
||||
|
||||
- `max_saved_sessions` (integer, default: `25`)
|
||||
The maximum number of saved sessions to keep.
|
||||
|
||||
- `session_timeout_minutes` (integer, default: `120`)
|
||||
The number of minutes of inactivity before a session is considered for auto-saving as a new session.
|
||||
|
||||
- `generate_descriptions` (boolean, default: `true`)
|
||||
Whether to automatically generate a short summary of a conversation when saving it.
|
||||
|
||||
## Input Settings (`[input]`)
|
||||
|
||||
These settings control the behavior of the text input area.
|
||||
|
||||
- `multiline` (boolean, default: `true`)
|
||||
Whether to allow multi-line input.
|
||||
|
||||
- `history_size` (integer, default: `100`)
|
||||
The number of sent messages to keep in the input history (accessible with `Ctrl-Up/Down`).
|
||||
|
||||
- `tab_width` (integer, default: `4`)
|
||||
The number of spaces to insert when the `Tab` key is pressed.
|
||||
|
||||
- `confirm_send` (boolean, default: `false`)
|
||||
If true, requires an additional confirmation before sending a message.
|
||||
|
||||
## Provider Settings (`[providers]`)
|
||||
|
||||
This section contains a table for each provider you want to configure. Owlen ships with two entries pre-populated: `ollama` for a local daemon and `ollama-cloud` for the hosted API. You can switch between them by changing `general.default_provider`.
|
||||
|
||||
```toml
|
||||
[providers.ollama]
|
||||
provider_type = "ollama"
|
||||
base_url = "http://localhost:11434"
|
||||
# api_key = "..."
|
||||
|
||||
[providers.ollama-cloud]
|
||||
provider_type = "ollama-cloud"
|
||||
base_url = "https://ollama.com"
|
||||
# api_key = "${OLLAMA_API_KEY}"
|
||||
```
|
||||
|
||||
- `provider_type` (string, required)
|
||||
The type of the provider. The built-in options are `"ollama"` (local daemon) and `"ollama-cloud"` (hosted service).
|
||||
|
||||
- `base_url` (string, optional)
|
||||
The base URL of the provider's API.
|
||||
|
||||
- `api_key` (string, optional)
|
||||
The API key to use for authentication, if required.
|
||||
|
||||
- `extra` (table, optional)
|
||||
Any additional, provider-specific parameters can be added here.
|
||||
|
||||
### Using Ollama Cloud
|
||||
|
||||
To talk to [Ollama Cloud](https://docs.ollama.com/cloud), point the base URL at the hosted endpoint and supply your API key:
|
||||
|
||||
```toml
|
||||
[providers.ollama-cloud]
|
||||
provider_type = "ollama-cloud"
|
||||
base_url = "https://ollama.com"
|
||||
api_key = "${OLLAMA_API_KEY}"
|
||||
```
|
||||
|
||||
Requests target the same `/api/chat` endpoint documented by Ollama and automatically include the API key using a `Bearer` authorization header. If you prefer not to store the key in the config file, you can leave `api_key` unset and provide it via the `OLLAMA_API_KEY` (or `OLLAMA_CLOUD_API_KEY`) environment variable instead. You can also reference an environment variable inline (for example `api_key = "$OLLAMA_API_KEY"` or `api_key = "${OLLAMA_API_KEY}"`), which Owlen expands when the configuration is loaded. The base URL is normalised automatically—Owlen enforces HTTPS, trims trailing slashes, and accepts both `https://ollama.com` and `https://api.ollama.com` without rewriting the host.
|
||||
42
docs/faq.md
Normal file
42
docs/faq.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Frequently Asked Questions (FAQ)
|
||||
|
||||
### What is the difference between `owlen` and `owlen-code`?
|
||||
|
||||
- `owlen` is the general-purpose chat client.
|
||||
- `owlen-code` is an experimental client with a system prompt that is optimized for programming and code-related questions. In the future, it will include more code-specific features like file context and syntax highlighting.
|
||||
|
||||
### How do I use Owlen with a different terminal?
|
||||
|
||||
Owlen is designed to work with most modern terminals that support 256 colors and Unicode. If you experience rendering issues, you might try:
|
||||
|
||||
- **WezTerm**: Excellent cross-platform, GPU-accelerated terminal.
|
||||
- **Alacritty**: Another fast, GPU-accelerated terminal.
|
||||
- **Kitty**: A feature-rich terminal emulator.
|
||||
|
||||
If issues persist, please open an issue and let us know what terminal you are using.
|
||||
|
||||
### What is the setup for Windows?
|
||||
|
||||
The Windows build is currently experimental. However, you can install it from source using `cargo` if you have the Rust toolchain installed.
|
||||
|
||||
1. Install Rust from [rustup.rs](https://rustup.rs).
|
||||
2. Install Git for Windows.
|
||||
3. Clone the repository: `git clone https://github.com/Owlibou/owlen.git`
|
||||
4. Install: `cd owlen && cargo install --path crates/owlen-cli`
|
||||
|
||||
Official binary releases for Windows are planned for the future.
|
||||
|
||||
### What is the setup for macOS?
|
||||
|
||||
Similar to Windows, the recommended installation method for macOS is to build from source using `cargo`.
|
||||
|
||||
1. Install the Xcode command-line tools: `xcode-select --install`
|
||||
2. Install Rust from [rustup.rs](https://rustup.rs).
|
||||
3. Clone the repository: `git clone https://github.com/Owlibou/owlen.git`
|
||||
4. Install: `cd owlen && cargo install --path crates/owlen-cli`
|
||||
|
||||
Official binary releases for macOS are planned.
|
||||
|
||||
### I'm getting connection failures to Ollama.
|
||||
|
||||
Please see the [Troubleshooting Guide](troubleshooting.md#connection-failures-to-ollama) for help with this common issue.
|
||||
34
docs/migration-guide.md
Normal file
34
docs/migration-guide.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Migration Guide
|
||||
|
||||
This guide documents breaking changes between versions of Owlen and provides instructions on how to migrate your configuration or usage.
|
||||
|
||||
As Owlen is currently in its alpha phase (pre-v1.0), breaking changes may occur more frequently. We will do our best to document them here.
|
||||
|
||||
---
|
||||
|
||||
## Migrating from v0.1.x to v0.2.x (Example)
|
||||
|
||||
*This is a template for a future migration. No breaking changes have occurred yet.*
|
||||
|
||||
Version 0.2.0 introduces a new configuration structure for providers.
|
||||
|
||||
### Configuration File Changes
|
||||
|
||||
Previously, your `config.toml` might have looked like this:
|
||||
|
||||
```toml
|
||||
# old config.toml (pre-v0.2.0)
|
||||
ollama_base_url = "http://localhost:11434"
|
||||
```
|
||||
|
||||
In v0.2.0, all provider settings are now nested under a `[providers]` table. You will need to update your `config.toml` to the new format:
|
||||
|
||||
```toml
|
||||
# new config.toml (v0.2.0+)
|
||||
[providers.ollama]
|
||||
base_url = "http://localhost:11434"
|
||||
```
|
||||
|
||||
### Action Required
|
||||
|
||||
Update your `~/.config/owlen/config.toml` to match the new structure. If you do not, Owlen will fall back to its default provider configuration.
|
||||
75
docs/provider-implementation.md
Normal file
75
docs/provider-implementation.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Provider Implementation Guide
|
||||
|
||||
This guide explains how to implement a new provider for Owlen. Providers are the components that connect to different LLM APIs.
|
||||
|
||||
## The `Provider` Trait
|
||||
|
||||
The core of the provider system is the `Provider` trait, located in `owlen-core`. Any new provider must implement this trait.
|
||||
|
||||
Here is a simplified version of the trait:
|
||||
|
||||
```rust
|
||||
use async_trait::async_trait;
|
||||
use owlen_core::model::Model;
|
||||
use owlen_core::session::Session;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Provider {
|
||||
/// Returns the name of the provider.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Sends the session to the provider and returns the response.
|
||||
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error>;
|
||||
}
|
||||
```
|
||||
|
||||
## Creating a New Crate
|
||||
|
||||
1. **Create a new crate** in the `crates/` directory. For example, `owlen-myprovider`.
|
||||
2. **Add dependencies** to your new crate's `Cargo.toml`. You will need `owlen-core`, `async-trait`, `tokio`, and any crates required for interacting with the new API (e.g., `reqwest`).
|
||||
3. **Add the new crate to the workspace** in the root `Cargo.toml`.
|
||||
|
||||
## Implementing the Trait
|
||||
|
||||
In your new crate's `lib.rs`, you will define a struct for your provider and implement the `Provider` trait for it.
|
||||
|
||||
```rust
|
||||
use async_trait::async_trait;
|
||||
use owlen_core::model::Model;
|
||||
use owlen_core::provider::Provider;
|
||||
use owlen_core::session::Session;
|
||||
|
||||
pub struct MyProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for MyProvider {
|
||||
fn name(&self) -> &str {
|
||||
"my-provider"
|
||||
}
|
||||
|
||||
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error> {
|
||||
// 1. Get the conversation history from the session.
|
||||
let history = session.get_messages();
|
||||
|
||||
// 2. Format the request for your provider's API.
|
||||
// This might involve creating a JSON body with the messages.
|
||||
|
||||
// 3. Send the request to the API using a client like reqwest.
|
||||
|
||||
// 4. Parse the response from the API.
|
||||
|
||||
// 5. Return the content of the response as a String.
|
||||
|
||||
Ok("Hello from my provider!".to_string())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integrating with Owlen
|
||||
|
||||
Once your provider is implemented, you will need to integrate it into the main Owlen application.
|
||||
|
||||
1. **Add your provider crate** as a dependency to `owlen-cli`.
|
||||
2. **In `owlen-cli`, modify the provider registration** to include your new provider. This will likely involve adding it to a list of available providers that the user can select from in the configuration.
|
||||
|
||||
This guide provides a basic outline. For more detailed examples, you can look at the existing provider implementations, such as `owlen-ollama`.
|
||||
58
docs/testing.md
Normal file
58
docs/testing.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Testing Guide
|
||||
|
||||
This guide provides instructions on how to run existing tests and how to write new tests for Owlen.
|
||||
|
||||
## Running Tests
|
||||
|
||||
The entire test suite can be run from the root of the repository using the standard `cargo test` command.
|
||||
|
||||
```sh
|
||||
# Run all tests in the workspace
|
||||
cargo test --all
|
||||
|
||||
# Run tests for a specific crate
|
||||
cargo test -p owlen-core
|
||||
```
|
||||
|
||||
We use `cargo clippy` for linting and `cargo fmt` for formatting. Please run these before submitting a pull request.
|
||||
|
||||
```sh
|
||||
cargo clippy --all -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
```
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
Tests are located in the `tests/` directory within each crate, or in a `tests` module at the bottom of the file they are testing. We follow standard Rust testing practices.
|
||||
|
||||
### Unit Tests
|
||||
|
||||
For testing specific functions or components in isolation, use unit tests. These should be placed in a `#[cfg(test)]` module in the same file as the code being tested.
|
||||
|
||||
```rust
|
||||
// in src/my_module.rs
|
||||
|
||||
pub fn add(a: i32, b: i32) -> i32 {
|
||||
a + b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_add() {
|
||||
assert_eq!(add(2, 2), 4);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
For testing how different parts of the application work together, use integration tests. These should be placed in the `tests/` directory of the crate.
|
||||
|
||||
For example, to test the `SessionController`, you might create a mock `Provider` and simulate sending messages, as seen in the `SessionController` documentation example.
|
||||
|
||||
### TUI and UI Component Tests
|
||||
|
||||
Testing TUI components can be challenging. For UI logic in `owlen-core` (like `wrap_cursor`), we have detailed unit tests that manipulate the component's state and assert the results. For higher-level TUI components in `owlen-tui`, the focus is on testing the state management logic rather than the visual output.
|
||||
40
docs/troubleshooting.md
Normal file
40
docs/troubleshooting.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
This guide is intended to help you with common issues you might encounter while using Owlen.
|
||||
|
||||
## Connection Failures to Ollama
|
||||
|
||||
If you are unable to connect to a local Ollama instance, here are a few things to check:
|
||||
|
||||
1. **Is Ollama running?** Make sure the Ollama service is active. You can usually check this with `ollama list`.
|
||||
2. **Is the address correct?** By default, Owlen tries to connect to `http://localhost:11434`. If your Ollama instance is running on a different address or port, you will need to configure it in your `config.toml` file.
|
||||
3. **Firewall issues:** Ensure that your firewall is not blocking the connection.
|
||||
|
||||
## Model Not Found Errors
|
||||
|
||||
If you get a "model not found" error, it means that the model you are trying to use is not available. For local providers like Ollama, you can use `ollama list` to see the models you have downloaded. Make sure the model name in your Owlen configuration matches one of the available models.
|
||||
|
||||
## Terminal Compatibility Issues
|
||||
|
||||
Owlen is built with `ratatui`, which supports most modern terminals. However, if you are experiencing rendering issues, please check the following:
|
||||
|
||||
- Your terminal supports Unicode.
|
||||
- You are using a font that includes the characters being displayed.
|
||||
- Try a different terminal emulator to see if the issue persists.
|
||||
|
||||
## Configuration File Problems
|
||||
|
||||
If Owlen is not behaving as you expect, there might be an issue with your configuration file.
|
||||
|
||||
- **Location:** The configuration file is typically located at `~/.config/owlen/config.toml`.
|
||||
- **Syntax:** The configuration file is in TOML format. Make sure the syntax is correct.
|
||||
- **Values:** Check that the values for your models, providers, and other settings are correct.
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
If you are experiencing performance issues, you can try the following:
|
||||
|
||||
- **Reduce context size:** A smaller context size will result in faster responses from the LLM.
|
||||
- **Use a less resource-intensive model:** Some models are faster but less capable than others.
|
||||
|
||||
If you are still having trouble, please [open an issue](https://github.com/Owlibou/owlen/issues) on our GitHub repository.
|
||||
30
examples/basic_chat.rs
Normal file
30
examples/basic_chat.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
// This example demonstrates a basic chat interaction without the TUI.
|
||||
|
||||
use owlen_core::model::Model;
|
||||
use owlen_core::provider::Provider;
|
||||
use owlen_core::session::Session;
|
||||
use owlen_ollama::OllamaProvider; // Assuming you have an Ollama provider
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), anyhow::Error> {
|
||||
// This example requires a running Ollama instance.
|
||||
// Make sure you have a model available, e.g., `ollama pull llama2`
|
||||
|
||||
let provider = OllamaProvider;
|
||||
let model = Model::new("llama2"); // Change to a model you have
|
||||
let mut session = Session::new("basic-chat-session");
|
||||
|
||||
println!("Starting basic chat with model: {}", model.name);
|
||||
|
||||
let user_message = "What is the capital of France?";
|
||||
session.add_message("user", user_message);
|
||||
println!("User: {}", user_message);
|
||||
|
||||
// Send the chat to the provider
|
||||
let response = provider.chat(&session, &model).await?;
|
||||
|
||||
session.add_message("bot", &response);
|
||||
println!("Bot: {}", response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
45
examples/custom_provider.rs
Normal file
45
examples/custom_provider.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
// This example demonstrates how to implement a custom provider.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use owlen_core::model::Model;
|
||||
use owlen_core::provider::Provider;
|
||||
use owlen_core::session::Session;
|
||||
|
||||
// Define a struct for your custom provider.
|
||||
pub struct MyCustomProvider;
|
||||
|
||||
// Implement the `Provider` trait for your struct.
|
||||
#[async_trait]
|
||||
impl Provider for MyCustomProvider {
|
||||
fn name(&self) -> &str {
|
||||
"custom-provider"
|
||||
}
|
||||
|
||||
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error> {
|
||||
println!(
|
||||
"Custom provider received chat request for model: {}",
|
||||
model.name
|
||||
);
|
||||
// In a real implementation, you would send the session data to an API.
|
||||
let message_count = session.get_messages().len();
|
||||
Ok(format!(
|
||||
"This is a custom response. You have {} messages in your session.",
|
||||
message_count
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), anyhow::Error> {
|
||||
let provider = MyCustomProvider;
|
||||
let model = Model::new("custom-model");
|
||||
let mut session = Session::new("custom-session");
|
||||
|
||||
session.add_message("user", "Hello, custom provider!");
|
||||
|
||||
let response = provider.chat(&session, &model).await?;
|
||||
|
||||
println!("Provider response: {}", response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
28
examples/custom_theme.rs
Normal file
28
examples/custom_theme.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
// This example demonstrates how to create a custom theme programmatically.
|
||||
|
||||
use owlen_core::theme::Theme;
|
||||
use ratatui::style::{Color, Style};
|
||||
|
||||
fn create_custom_theme() -> Theme {
|
||||
Theme {
|
||||
name: "My Custom Theme".to_string(),
|
||||
author: "Your Name".to_string(),
|
||||
comment: "A simple custom theme".to_string(),
|
||||
base: Style::default().fg(Color::White).bg(Color::Black),
|
||||
user_chat: Style::default().fg(Color::Green),
|
||||
bot_chat: Style::default().fg(Color::Cyan),
|
||||
error: Style::default().fg(Color::Red),
|
||||
info: Style::default().fg(Color::Yellow),
|
||||
border: Style::default().fg(Color::Gray),
|
||||
input: Style::default().fg(Color::White),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let custom_theme = create_custom_theme();
|
||||
|
||||
println!("Created custom theme: {}", custom_theme.name);
|
||||
println!("Author: {}", custom_theme.author);
|
||||
println!("User chat color: {:?}", custom_theme.user_chat.fg);
|
||||
}
|
||||
30
examples/session_management.rs
Normal file
30
examples/session_management.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
// This example demonstrates how to use the session controller.
|
||||
|
||||
use owlen_core::session::Session;
|
||||
|
||||
fn main() {
|
||||
// Create a new session.
|
||||
let mut session = Session::new("my-session");
|
||||
println!("Created new session: {}", session.name);
|
||||
|
||||
// Add messages to the session.
|
||||
session.add_message("user", "Hello, Owlen!");
|
||||
session.add_message("bot", "Hello, user! How can I help you today?");
|
||||
|
||||
// Get the messages from the session.
|
||||
let messages = session.get_messages();
|
||||
println!("\nMessages in session:");
|
||||
for message in messages {
|
||||
println!(" {}: {}", message.role, message.content);
|
||||
}
|
||||
|
||||
// Clear the session.
|
||||
session.clear_messages();
|
||||
println!("\nSession cleared.");
|
||||
|
||||
let messages_after_clear = session.get_messages();
|
||||
println!(
|
||||
"Messages in session after clear: {}",
|
||||
messages_after_clear.len()
|
||||
);
|
||||
}
|
||||
89
themes/README.md
Normal file
89
themes/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# OWLEN Built-in Themes
|
||||
|
||||
This directory contains the built-in themes that are embedded into the OWLEN binary.
|
||||
|
||||
## Available Themes
|
||||
|
||||
- **default_dark** - High-contrast dark theme (default)
|
||||
- **default_light** - Clean light theme
|
||||
- **gruvbox** - Popular retro color scheme with warm tones
|
||||
- **dracula** - Dark theme with vibrant purple and cyan colors
|
||||
- **solarized** - Precision colors for optimal readability
|
||||
- **midnight-ocean** - Deep blue oceanic theme
|
||||
- **rose-pine** - Soho vibes with muted pastels
|
||||
- **monokai** - Classic code editor theme
|
||||
- **material-dark** - Google's Material Design dark variant
|
||||
- **material-light** - Google's Material Design light variant
|
||||
|
||||
## Theme File Format
|
||||
|
||||
Each theme is defined in TOML format with the following structure:
|
||||
|
||||
```toml
|
||||
name = "theme-name"
|
||||
|
||||
# Text colors
|
||||
text = "#ffffff" # Main text color
|
||||
placeholder = "#808080" # Placeholder/muted text
|
||||
|
||||
# Background colors
|
||||
background = "#000000" # Main background
|
||||
command_bar_background = "#111111"
|
||||
status_background = "#111111"
|
||||
|
||||
# Border colors
|
||||
focused_panel_border = "#ff00ff" # Active panel border
|
||||
unfocused_panel_border = "#800080" # Inactive panel border
|
||||
|
||||
# Message role colors
|
||||
user_message_role = "#00ffff" # User messages
|
||||
assistant_message_role = "#ffff00" # Assistant messages
|
||||
thinking_panel_title = "#ff00ff" # Thinking panel title
|
||||
|
||||
# Mode indicator colors (status bar)
|
||||
mode_normal = "#00ffff"
|
||||
mode_editing = "#00ff00"
|
||||
mode_model_selection = "#ffff00"
|
||||
mode_provider_selection = "#00ffff"
|
||||
mode_help = "#ff00ff"
|
||||
mode_visual = "#ff0080"
|
||||
mode_command = "#ffff00"
|
||||
|
||||
# Selection and cursor
|
||||
selection_bg = "#0000ff" # Selection background
|
||||
selection_fg = "#ffffff" # Selection foreground
|
||||
cursor = "#ff0080" # Cursor color
|
||||
|
||||
# Status colors
|
||||
error = "#ff0000" # Error messages
|
||||
info = "#00ff00" # Info/success messages
|
||||
```
|
||||
|
||||
## Color Format
|
||||
|
||||
Colors can be specified in two formats:
|
||||
|
||||
1. **Hex RGB**: `#rrggbb` (e.g., `#ff0000` for red, `#ff8800` for orange)
|
||||
2. **Named colors** (case-insensitive):
|
||||
- **Basic**: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`
|
||||
- **Gray variants**: `gray`, `grey`, `darkgray`, `darkgrey`
|
||||
- **Light variants**: `lightred`, `lightgreen`, `lightyellow`, `lightblue`, `lightmagenta`, `lightcyan`
|
||||
|
||||
**Note**: For colors not in the named list (like orange, purple, brown), use hex RGB format.
|
||||
|
||||
OWLEN will display an error message on startup if a custom theme has invalid colors.
|
||||
|
||||
## Creating Custom Themes
|
||||
|
||||
To create your own theme:
|
||||
|
||||
1. Copy one of these files to `~/.config/owlen/themes/`
|
||||
2. Rename and modify the colors
|
||||
3. Set `theme = "your-theme-name"` in `~/.config/owlen/config.toml`
|
||||
4. Or use `:theme your-theme-name` in OWLEN to switch
|
||||
|
||||
## Embedding in Binary
|
||||
|
||||
These theme files are embedded into the OWLEN binary at compile time using Rust's `include_str!()` macro. This ensures they're always available, even if the files are deleted from disk.
|
||||
|
||||
Custom themes placed in `~/.config/owlen/themes/` will override built-in themes with the same name.
|
||||
23
themes/default_dark.toml
Normal file
23
themes/default_dark.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
name = "default_dark"
|
||||
text = "white"
|
||||
background = "black"
|
||||
focused_panel_border = "lightmagenta"
|
||||
unfocused_panel_border = "#5f1487"
|
||||
user_message_role = "lightblue"
|
||||
assistant_message_role = "yellow"
|
||||
thinking_panel_title = "lightmagenta"
|
||||
command_bar_background = "black"
|
||||
status_background = "black"
|
||||
mode_normal = "lightblue"
|
||||
mode_editing = "lightgreen"
|
||||
mode_model_selection = "lightyellow"
|
||||
mode_provider_selection = "lightcyan"
|
||||
mode_help = "lightmagenta"
|
||||
mode_visual = "magenta"
|
||||
mode_command = "yellow"
|
||||
selection_bg = "lightblue"
|
||||
selection_fg = "black"
|
||||
cursor = "magenta"
|
||||
placeholder = "darkgray"
|
||||
error = "red"
|
||||
info = "lightgreen"
|
||||
23
themes/default_light.toml
Normal file
23
themes/default_light.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
name = "default_light"
|
||||
text = "black"
|
||||
background = "white"
|
||||
focused_panel_border = "#4a90e2"
|
||||
unfocused_panel_border = "#dddddd"
|
||||
user_message_role = "#0055a4"
|
||||
assistant_message_role = "#8e44ad"
|
||||
thinking_panel_title = "#8e44ad"
|
||||
command_bar_background = "white"
|
||||
status_background = "white"
|
||||
mode_normal = "#0055a4"
|
||||
mode_editing = "#2e8b57"
|
||||
mode_model_selection = "#b58900"
|
||||
mode_provider_selection = "#008b8b"
|
||||
mode_help = "#8e44ad"
|
||||
mode_visual = "#8e44ad"
|
||||
mode_command = "#b58900"
|
||||
selection_bg = "#a4c8f0"
|
||||
selection_fg = "black"
|
||||
cursor = "#d95f02"
|
||||
placeholder = "gray"
|
||||
error = "#c0392b"
|
||||
info = "green"
|
||||
23
themes/dracula.toml
Normal file
23
themes/dracula.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
name = "dracula"
|
||||
text = "#f8f8f2"
|
||||
background = "#282a36"
|
||||
focused_panel_border = "#ff79c6"
|
||||
unfocused_panel_border = "#44475a"
|
||||
user_message_role = "#8be9fd"
|
||||
assistant_message_role = "#ff79c6"
|
||||
thinking_panel_title = "#bd93f9"
|
||||
command_bar_background = "#44475a"
|
||||
status_background = "#44475a"
|
||||
mode_normal = "#8be9fd"
|
||||
mode_editing = "#50fa7b"
|
||||
mode_model_selection = "#f1fa8c"
|
||||
mode_provider_selection = "#8be9fd"
|
||||
mode_help = "#bd93f9"
|
||||
mode_visual = "#ff79c6"
|
||||
mode_command = "#f1fa8c"
|
||||
selection_bg = "#44475a"
|
||||
selection_fg = "#f8f8f2"
|
||||
cursor = "#ff79c6"
|
||||
placeholder = "#6272a4"
|
||||
error = "#ff5555"
|
||||
info = "#50fa7b"
|
||||
23
themes/gruvbox.toml
Normal file
23
themes/gruvbox.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
name = "gruvbox"
|
||||
text = "#ebdbb2"
|
||||
background = "#282828"
|
||||
focused_panel_border = "#fe8019"
|
||||
unfocused_panel_border = "#7c6f64"
|
||||
user_message_role = "#b8bb26"
|
||||
assistant_message_role = "#83a598"
|
||||
thinking_panel_title = "#d3869b"
|
||||
command_bar_background = "#3c3836"
|
||||
status_background = "#3c3836"
|
||||
mode_normal = "#83a598"
|
||||
mode_editing = "#b8bb26"
|
||||
mode_model_selection = "#fabd2f"
|
||||
mode_provider_selection = "#8ec07c"
|
||||
mode_help = "#d3869b"
|
||||
mode_visual = "#fe8019"
|
||||
mode_command = "#fabd2f"
|
||||
selection_bg = "#504945"
|
||||
selection_fg = "#ebdbb2"
|
||||
cursor = "#fe8019"
|
||||
placeholder = "#665c54"
|
||||
error = "#fb4934"
|
||||
info = "#b8bb26"
|
||||
23
themes/material-dark.toml
Normal file
23
themes/material-dark.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
name = "material-dark"
|
||||
text = "#eeffff"
|
||||
background = "#263238"
|
||||
focused_panel_border = "#80cbc4"
|
||||
unfocused_panel_border = "#546e7a"
|
||||
user_message_role = "#82aaff"
|
||||
assistant_message_role = "#c792ea"
|
||||
thinking_panel_title = "#ffcb6b"
|
||||
command_bar_background = "#212b30"
|
||||
status_background = "#212b30"
|
||||
mode_normal = "#82aaff"
|
||||
mode_editing = "#c3e88d"
|
||||
mode_model_selection = "#ffcb6b"
|
||||
mode_provider_selection = "#80cbc4"
|
||||
mode_help = "#c792ea"
|
||||
mode_visual = "#f07178"
|
||||
mode_command = "#ffcb6b"
|
||||
selection_bg = "#546e7a"
|
||||
selection_fg = "#eeffff"
|
||||
cursor = "#ffcc00"
|
||||
placeholder = "#546e7a"
|
||||
error = "#f07178"
|
||||
info = "#c3e88d"
|
||||
23
themes/material-light.toml
Normal file
23
themes/material-light.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
name = "material-light"
|
||||
text = "#212121"
|
||||
background = "#eceff1"
|
||||
focused_panel_border = "#009688"
|
||||
unfocused_panel_border = "#b0bec5"
|
||||
user_message_role = "#448aff"
|
||||
assistant_message_role = "#7c4dff"
|
||||
thinking_panel_title = "#f57c00"
|
||||
command_bar_background = "#ffffff"
|
||||
status_background = "#ffffff"
|
||||
mode_normal = "#448aff"
|
||||
mode_editing = "#388e3c"
|
||||
mode_model_selection = "#f57c00"
|
||||
mode_provider_selection = "#009688"
|
||||
mode_help = "#7c4dff"
|
||||
mode_visual = "#d32f2f"
|
||||
mode_command = "#f57c00"
|
||||
selection_bg = "#b0bec5"
|
||||
selection_fg = "#212121"
|
||||
cursor = "#c2185b"
|
||||
placeholder = "#90a4ae"
|
||||
error = "#d32f2f"
|
||||
info = "#388e3c"
|
||||
23
themes/midnight-ocean.toml
Normal file
23
themes/midnight-ocean.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
name = "midnight-ocean"
|
||||
text = "#c0caf5"
|
||||
background = "#0d1117"
|
||||
focused_panel_border = "#58a6ff"
|
||||
unfocused_panel_border = "#30363d"
|
||||
user_message_role = "#79c0ff"
|
||||
assistant_message_role = "#89ddff"
|
||||
thinking_panel_title = "#9ece6a"
|
||||
command_bar_background = "#161b22"
|
||||
status_background = "#161b22"
|
||||
mode_normal = "#79c0ff"
|
||||
mode_editing = "#9ece6a"
|
||||
mode_model_selection = "#ffd43b"
|
||||
mode_provider_selection = "#89ddff"
|
||||
mode_help = "#ff739d"
|
||||
mode_visual = "#f68cf5"
|
||||
mode_command = "#ffd43b"
|
||||
selection_bg = "#388bfd"
|
||||
selection_fg = "#0d1117"
|
||||
cursor = "#f68cf5"
|
||||
placeholder = "#6e7681"
|
||||
error = "#f85149"
|
||||
info = "#9ece6a"
|
||||
23
themes/monokai.toml
Normal file
23
themes/monokai.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
name = "monokai"
|
||||
text = "#f8f8f2"
|
||||
background = "#272822"
|
||||
focused_panel_border = "#f92672"
|
||||
unfocused_panel_border = "#75715e"
|
||||
user_message_role = "#66d9ef"
|
||||
assistant_message_role = "#ae81ff"
|
||||
thinking_panel_title = "#e6db74"
|
||||
command_bar_background = "#272822"
|
||||
status_background = "#272822"
|
||||
mode_normal = "#66d9ef"
|
||||
mode_editing = "#a6e22e"
|
||||
mode_model_selection = "#e6db74"
|
||||
mode_provider_selection = "#66d9ef"
|
||||
mode_help = "#ae81ff"
|
||||
mode_visual = "#f92672"
|
||||
mode_command = "#e6db74"
|
||||
selection_bg = "#75715e"
|
||||
selection_fg = "#f8f8f2"
|
||||
cursor = "#f92672"
|
||||
placeholder = "#75715e"
|
||||
error = "#f92672"
|
||||
info = "#a6e22e"
|
||||
23
themes/rose-pine.toml
Normal file
23
themes/rose-pine.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
name = "rose-pine"
|
||||
text = "#e0def4"
|
||||
background = "#191724"
|
||||
focused_panel_border = "#eb6f92"
|
||||
unfocused_panel_border = "#26233a"
|
||||
user_message_role = "#31748f"
|
||||
assistant_message_role = "#9ccfd8"
|
||||
thinking_panel_title = "#c4a7e7"
|
||||
command_bar_background = "#26233a"
|
||||
status_background = "#26233a"
|
||||
mode_normal = "#9ccfd8"
|
||||
mode_editing = "#ebbcba"
|
||||
mode_model_selection = "#f6c177"
|
||||
mode_provider_selection = "#31748f"
|
||||
mode_help = "#c4a7e7"
|
||||
mode_visual = "#eb6f92"
|
||||
mode_command = "#f6c177"
|
||||
selection_bg = "#403d52"
|
||||
selection_fg = "#e0def4"
|
||||
cursor = "#eb6f92"
|
||||
placeholder = "#6e6a86"
|
||||
error = "#eb6f92"
|
||||
info = "#9ccfd8"
|
||||
23
themes/solarized.toml
Normal file
23
themes/solarized.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
name = "solarized"
|
||||
text = "#839496"
|
||||
background = "#002b36"
|
||||
focused_panel_border = "#268bd2"
|
||||
unfocused_panel_border = "#073642"
|
||||
user_message_role = "#2aa198"
|
||||
assistant_message_role = "#cb4b16"
|
||||
thinking_panel_title = "#6c71c4"
|
||||
command_bar_background = "#073642"
|
||||
status_background = "#073642"
|
||||
mode_normal = "#268bd2"
|
||||
mode_editing = "#859900"
|
||||
mode_model_selection = "#b58900"
|
||||
mode_provider_selection = "#2aa198"
|
||||
mode_help = "#6c71c4"
|
||||
mode_visual = "#d33682"
|
||||
mode_command = "#b58900"
|
||||
selection_bg = "#073642"
|
||||
selection_fg = "#93a1a1"
|
||||
cursor = "#d33682"
|
||||
placeholder = "#586e75"
|
||||
error = "#dc322f"
|
||||
info = "#859900"
|
||||
Reference in New Issue
Block a user