mirror of
https://github.com/perstarkse/minne.git
synced 2026-02-24 00:54:49 +01:00
Compare commits
16 Commits
developmen
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96846ad664 | ||
|
|
269bcec659 | ||
|
|
7c738c4b30 | ||
|
|
cb88127fcb | ||
|
|
49e1fbd985 | ||
|
|
f2fa5bbbcc | ||
|
|
a3bc6fba98 | ||
|
|
ece744d5a0 | ||
|
|
a9fda67209 | ||
|
|
fa7f407306 | ||
|
|
b25cfb4633 | ||
|
|
0df2b9810c | ||
|
|
354dc727c1 | ||
|
|
037057d108 | ||
|
|
9f17c6c2b0 | ||
|
|
17f252e630 |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,8 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
## Unreleased
|
## 1.0.1 (2026-02-11)
|
||||||
|
- Shipped an S3 storage backend so content can be stored in object storage instead of local disk, with configuration support for S3 deployments.
|
||||||
|
- Introduced user theme preferences with the new Obsidian Prism look and improved dark mode styling.
|
||||||
|
- Fixed edge cases, including content deletion behavior and compatibility for older user records.
|
||||||
|
|
||||||
|
## 1.0.0 (2026-01-02)
|
||||||
|
- **Locally generated embeddings are now default**. If you want to continue using API embeddings, set EMBEDDING_BACKEND to openai. This will download a ONNX model and recreate all embeddings. But in most instances it's very worth it. Removing the network bound call to create embeddings. Creating embeddings on my N100 device is extremely fast. Typically a search response is provided in less than 50ms.
|
||||||
- Added a benchmarks create for evaluating the retrieval process
|
- Added a benchmarks create for evaluating the retrieval process
|
||||||
- Added fastembed embedding support, enables the use of local CPU generated embeddings, greatly improved latency if machine can handle it. Quick search has vastly better accuracy and is much faster, 50ms latency when testing compared to minimum 300ms.
|
- Added fastembed embedding support, enables the use of local CPU generated embeddings, greatly improved latency if machine can handle it. Quick search has vastly better accuracy and is much faster, 50ms latency when testing compared to minimum 300ms.
|
||||||
- Embeddings stored on own table
|
- Embeddings stored on own table.
|
||||||
- Refactored retrieval pipeline to use the new, faster and more accurate strategy. Read [blog post](https://blog.stark.pub/posts/eval-retrieval-refactor/) for more details.
|
- Refactored retrieval pipeline to use the new, faster and more accurate strategy. Read [blog post](https://blog.stark.pub/posts/eval-retrieval-refactor/) for more details.
|
||||||
|
|
||||||
## Version 0.2.7 (2025-12-04)
|
## Version 0.2.7 (2025-12-04)
|
||||||
|
|||||||
21
Cargo.lock
generated
21
Cargo.lock
generated
@@ -3820,7 +3820,7 @@ checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "main"
|
name = "main"
|
||||||
version = "0.2.7"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"api-router",
|
"api-router",
|
||||||
@@ -4389,13 +4389,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3cfccb68961a56facde1163f9319e0d15743352344e7808a11795fb99698dcaf"
|
checksum = "3cfccb68961a56facde1163f9319e0d15743352344e7808a11795fb99698dcaf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"humantime",
|
"humantime",
|
||||||
|
"hyper",
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
|
"md-5",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"quick-xml",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"reqwest",
|
||||||
|
"ring",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"snafu",
|
"snafu",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -5064,6 +5073,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.37.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick_cache"
|
name = "quick_cache"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ url = { version = "2.5.2", features = ["serde"] }
|
|||||||
uuid = { version = "1.10.0", features = ["v4", "serde"] }
|
uuid = { version = "1.10.0", features = ["v4", "serde"] }
|
||||||
tokio-retry = "0.3.0"
|
tokio-retry = "0.3.0"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
object_store = { version = "0.11.2" }
|
object_store = { version = "0.11.2", features = ["aws"] }
|
||||||
bytes = "1.7.1"
|
bytes = "1.7.1"
|
||||||
state-machines = "0.2.0"
|
state-machines = "0.2.0"
|
||||||
fastembed = { version = "5.2.0", default-features = false, features = ["hf-hub-native-tls", "ort-load-dynamic"] }
|
fastembed = { version = "5.2.0", default-features = false, features = ["hf-hub-native-tls", "ort-load-dynamic"] }
|
||||||
|
|||||||
261
README.md
261
README.md
@@ -1,66 +1,265 @@
|
|||||||
# Minne
|
# Minne - A Graph-Powered Personal Knowledge Base
|
||||||
|
|
||||||
**A graph-powered personal knowledge base that remembers for you.**
|
**Minne (Swedish for "memory")** is a personal knowledge management system and save-for-later application for capturing, organizing, and accessing your information. Inspired by the Zettelkasten method, it uses a graph database to automatically create connections between your notes without manual linking overhead.
|
||||||
|
|
||||||
Capture content effortlessly, let AI discover connections, and explore your knowledge visually. Self-hosted and privacy-focused.
|
|
||||||
|
|
||||||
[](https://github.com/perstarkse/minne/actions/workflows/release.yml)
|
[](https://github.com/perstarkse/minne/actions/workflows/release.yml)
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
[](https://github.com/perstarkse/minne/releases/latest)
|
[](https://github.com/perstarkse/minne/releases/latest)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Try It
|
## Demo deployment
|
||||||
|
|
||||||
**[Live Demo](https://minne-demo.stark.pub)** — Read-only demo deployment
|
To test _Minne_ out, enter [this](https://minne.stark.pub) and sign in to a read-only demo deployment to view and test functionality out.
|
||||||
|
|
||||||
|
## Noteworthy Features
|
||||||
|
|
||||||
|
- **Search & Chat Interface** - Find content or knowledge instantly with full-text search, or use the chat mode and conversational AI to find and reason about content
|
||||||
|
- **Manual and AI-assisted connections** - Build entities and relationships manually with full control, let AI create entities and relationships automatically, or blend both approaches with AI suggestions for manual approval
|
||||||
|
- **Hybrid Retrieval System** - Search combining vector similarity & full-text search
|
||||||
|
- **Scratchpad Feature** - Quickly capture thoughts and convert them to permanent content when ready
|
||||||
|
- **Visual Graph Explorer** - Interactive D3-based navigation of your knowledge entities and connections
|
||||||
|
- **Multi-Format Support** - Ingest text, URLs, PDFs, audio files, and images into your knowledge base
|
||||||
|
- **Performance Focus** - Built with Rust and server-side rendering for speed and efficiency
|
||||||
|
- **Self-Hosted & Privacy-Focused** - Full control over your data, and compatible with any OpenAI-compatible API that supports structured outputs
|
||||||
|
|
||||||
|
## The "Why" Behind Minne
|
||||||
|
|
||||||
|
For a while I've been fascinated by personal knowledge management systems. I wanted something that made it incredibly easy to capture content - snippets of text, URLs, and other media - while automatically discovering connections between ideas. But I also wanted to maintain control over my knowledge structure.
|
||||||
|
|
||||||
|
Traditional tools like Logseq and Obsidian are excellent, but the manual linking process often became a hindrance. Meanwhile, fully automated systems sometimes miss important context or create relationships I wouldn't have chosen myself.
|
||||||
|
|
||||||
|
So I built Minne to offer the best of both worlds: effortless content capture with AI-assisted relationship discovery, but with the flexibility to manually curate, edit, or override any connections. You can let AI handle the heavy lifting of extracting entities and finding relationships, take full control yourself, or use a hybrid approach where AI suggests connections that you can approve or modify.
|
||||||
|
|
||||||
|
While developing Minne, I discovered [KaraKeep](https://github.com/karakeep-app/karakeep) (formerly Hoarder), which is an excellent application in a similar space – you probably want to check it out! However, if you're interested in a PKM that offers both intelligent automation and manual curation, with the ability to chat with your knowledge base, then Minne might be worth testing.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Features in Detail](#features-in-detail)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
|
- [Application Architecture](#application-architecture)
|
||||||
|
- [AI Configuration](#ai-configuration--model-selection)
|
||||||
|
- [Roadmap](#roadmap)
|
||||||
|
- [Development](#development)
|
||||||
|
- [Contributing](#contributing)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
The fastest way to get Minne running is with Docker Compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Clone the repository
|
||||||
git clone https://github.com/perstarkse/minne.git
|
git clone https://github.com/perstarkse/minne.git
|
||||||
cd minne
|
cd minne
|
||||||
|
|
||||||
# Set your OpenAI API key in docker-compose.yml, then:
|
# Start Minne and its database
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Open http://localhost:3000
|
# Access at http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with Nix (with environment variables set):
|
**Required Setup:**
|
||||||
|
- Replace `your_openai_api_key_here` in `docker-compose.yml` with your actual API key
|
||||||
|
- Configure `OPENAI_BASE_URL` if using a custom AI provider (like Ollama)
|
||||||
|
|
||||||
|
For detailed installation options, see [Configuration](#configuration).
|
||||||
|
|
||||||
|
## Features in Detail
|
||||||
|
|
||||||
|
### Search vs. Chat mode
|
||||||
|
|
||||||
|
**Search** - Use when you know roughly what you're looking for. Full-text search finds items quickly by matching your query terms.
|
||||||
|
|
||||||
|
**Chat Mode** - Use when you want to explore concepts, find connections, or reason about your knowledge. The AI analyzes your query and finds relevant context across your entire knowledge base.
|
||||||
|
|
||||||
|
### Content Processing
|
||||||
|
|
||||||
|
Minne automatically processes content you save:
|
||||||
|
1. **Web scraping** extracts readable text from URLs
|
||||||
|
2. **Text analysis** identifies key concepts and relationships
|
||||||
|
3. **Graph creation** builds connections between related content
|
||||||
|
4. **Embedding generation** enables semantic search capabilities
|
||||||
|
|
||||||
|
### Visual Knowledge Graph
|
||||||
|
|
||||||
|
Explore your knowledge as an interactive network with flexible curation options:
|
||||||
|
|
||||||
|
**Manual Curation** - Create knowledge entities and relationships yourself with full control over your graph structure
|
||||||
|
|
||||||
|
**AI Automation** - Let AI automatically extract entities and discover relationships from your content
|
||||||
|
|
||||||
|
**Hybrid Approach** - Get AI-suggested relationships and entities that you can manually review, edit, or approve
|
||||||
|
|
||||||
|
The graph visualization shows:
|
||||||
|
- Knowledge entities as nodes (manually created or AI-extracted)
|
||||||
|
- Relationships as connections (manually defined, AI-discovered, or suggested)
|
||||||
|
- Interactive navigation for discovery and editing
|
||||||
|
|
||||||
|
### Optional FastEmbed Reranking
|
||||||
|
|
||||||
|
Minne ships with an opt-in reranking stage powered by [fastembed-rs](https://github.com/Anush008/fastembed-rs). When enabled, the hybrid retrieval results are rescored with a lightweight cross-encoder before being returned to chat or ingestion flows. In practice this often means more relevant results, boosting answer quality and downstream enrichment.
|
||||||
|
|
||||||
|
⚠️ **Resource notes**
|
||||||
|
- Enabling reranking downloads and caches ~1.1 GB of model data on first startup (cached under `<data_dir>/fastembed/reranker` by default).
|
||||||
|
- Initialization takes longer while warming the cache, and each query consumes extra CPU. The default pool size (2) is tuned for a singe user setup, but could work with a pool size on 1 as well.
|
||||||
|
- The feature is disabled by default. Set `reranking_enabled: true` (or `RERANKING_ENABLED=true`) if you’re comfortable with the additional footprint.
|
||||||
|
|
||||||
|
Example configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
reranking_enabled: true
|
||||||
|
reranking_pool_size: 2
|
||||||
|
fastembed_cache_dir: "/var/lib/minne/fastembed" # optional override, defaults to .fastembed_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend:** Rust with Axum framework and Server-Side Rendering (SSR)
|
||||||
|
- **Frontend:** HTML with HTMX and minimal JavaScript for interactivity
|
||||||
|
- **Database:** SurrealDB (graph, document, and vector search)
|
||||||
|
- **AI Integration:** OpenAI-compatible API with structured outputs
|
||||||
|
- **Web Processing:** Headless Chrome for robust webpage content extraction
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Minne can be configured using environment variables or a `config.yaml` file. Environment variables take precedence over `config.yaml`.
|
||||||
|
|
||||||
|
### Required Configuration
|
||||||
|
|
||||||
|
- `SURREALDB_ADDRESS`: WebSocket address of your SurrealDB instance (e.g., `ws://127.0.0.1:8000`)
|
||||||
|
- `SURREALDB_USERNAME`: Username for SurrealDB (e.g., `root_user`)
|
||||||
|
- `SURREALDB_PASSWORD`: Password for SurrealDB (e.g., `root_password`)
|
||||||
|
- `SURREALDB_DATABASE`: Database name in SurrealDB (e.g., `minne_db`)
|
||||||
|
- `SURREALDB_NAMESPACE`: Namespace in SurrealDB (e.g., `minne_ns`)
|
||||||
|
- `OPENAI_API_KEY`: Your API key for OpenAI compatible endpoint
|
||||||
|
- `HTTP_PORT`: Port for the Minne server (Default: `3000`)
|
||||||
|
|
||||||
|
### Optional Configuration
|
||||||
|
|
||||||
|
- `RUST_LOG`: Controls logging level (e.g., `minne=info,tower_http=debug`)
|
||||||
|
- `DATA_DIR`: Directory to store local data (e.g., `./data`)
|
||||||
|
- `OPENAI_BASE_URL`: Base URL for custom AI providers (like Ollama)
|
||||||
|
- `RERANKING_ENABLED` / `reranking_enabled`: Set to `true` to enable the FastEmbed reranking stage (default `false`)
|
||||||
|
- `RERANKING_POOL_SIZE` / `reranking_pool_size`: Maximum concurrent reranker workers (defaults to `2`)
|
||||||
|
- `FASTEMBED_CACHE_DIR` / `fastembed_cache_dir`: Directory for cached FastEmbed models (defaults to `<data_dir>/fastembed/reranker`)
|
||||||
|
- `FASTEMBED_SHOW_DOWNLOAD_PROGRESS` / `fastembed_show_download_progress`: Show model download progress when warming the cache (default `true`)
|
||||||
|
|
||||||
|
### Example config.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
surrealdb_address: "ws://127.0.0.1:8000"
|
||||||
|
surrealdb_username: "root_user"
|
||||||
|
surrealdb_password: "root_password"
|
||||||
|
surrealdb_database: "minne_db"
|
||||||
|
surrealdb_namespace: "minne_ns"
|
||||||
|
openai_api_key: "sk-YourActualOpenAIKeyGoesHere"
|
||||||
|
data_dir: "./minne_app_data"
|
||||||
|
http_port: 3000
|
||||||
|
# rust_log: "info"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation Options
|
||||||
|
|
||||||
|
### 1. Docker Compose (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and run
|
||||||
|
git clone https://github.com/perstarkse/minne.git
|
||||||
|
cd minne
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The included `docker-compose.yml` handles SurrealDB and Chromium dependencies automatically.
|
||||||
|
|
||||||
|
### 2. Nix
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run 'github:perstarkse/minne#main'
|
nix run 'github:perstarkse/minne#main'
|
||||||
```
|
```
|
||||||
|
|
||||||
Pre-built binaries for Windows, macOS, and Linux are available on the [Releases](https://github.com/perstarkse/minne/releases/latest) page.
|
This fetches Minne and all dependencies, including Chromium.
|
||||||
|
|
||||||
## Features
|
### 3. Pre-built Binaries
|
||||||
|
|
||||||
- **Fast** — Rust backend with server-side rendering and HTMX for snappy interactions
|
Download binaries for Windows, macOS, and Linux from the [GitHub Releases](https://github.com/perstarkse/minne/releases/latest).
|
||||||
- **Search & Chat** — Search or use conversational AI to find and reason about content
|
|
||||||
- **Knowledge Graph** — Visual exploration with automatic or manual relationship curation
|
|
||||||
- **Hybrid Retrieval** — Vector similarity + full-text for relevant results
|
|
||||||
- **Multi-Format** — Ingest text, URLs, PDFs, audio, and images
|
|
||||||
- **Self-Hosted** — Your data, your server, any OpenAI-compatible API
|
|
||||||
|
|
||||||
## Documentation
|
**Requirements:** You'll need to provide SurrealDB and Chromium separately.
|
||||||
|
|
||||||
| Guide | Description |
|
### 4. Build from Source
|
||||||
|-------|-------------|
|
|
||||||
| [Installation](docs/installation.md) | Docker, Nix, binaries, source builds |
|
|
||||||
| [Configuration](docs/configuration.md) | Environment variables, config.yaml, AI setup |
|
|
||||||
| [Features](docs/features.md) | Search, Chat, Graph, Reranking, Ingestion |
|
|
||||||
| [Architecture](docs/architecture.md) | Tech stack, crate structure, data flow |
|
|
||||||
| [Vision](docs/vision.md) | Philosophy, roadmap, related projects |
|
|
||||||
|
|
||||||
## Tech Stack
|
```bash
|
||||||
|
git clone https://github.com/perstarkse/minne.git
|
||||||
|
cd minne
|
||||||
|
cargo run --release --bin main
|
||||||
|
```
|
||||||
|
|
||||||
Rust • Axum • HTMX • SurrealDB • FastEmbed
|
**Requirements:** SurrealDB and Chromium must be installed and accessible in your PATH.
|
||||||
|
|
||||||
|
## Application Architecture
|
||||||
|
|
||||||
|
Minne offers flexible deployment options:
|
||||||
|
|
||||||
|
- **`main`**: Combined server and worker in one process (recommended for most users)
|
||||||
|
- **`server`**: Web interface and API only
|
||||||
|
- **`worker`**: Background processing only (for resource optimization)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Once Minne is running at `http://localhost:3000`:
|
||||||
|
|
||||||
|
1. **Web Interface**: Full-featured experience for desktop and mobile
|
||||||
|
2. **iOS Shortcut**: Use the [Minne iOS Shortcut](https://www.icloud.com/shortcuts/e433fbd7602f4e2eaa70dca162323477) for quick content capture
|
||||||
|
3. **Content Types**: Save notes, URLs, audio files, and more
|
||||||
|
4. **Knowledge Graph**: Explore automatic connections between your content
|
||||||
|
5. **Chat Interface**: Query your knowledge base conversationally
|
||||||
|
|
||||||
|
## AI Configuration & Model Selection
|
||||||
|
|
||||||
|
### Setting Up AI Providers
|
||||||
|
|
||||||
|
Minne uses OpenAI-compatible APIs. Configure via environment variables or `config.yaml`:
|
||||||
|
|
||||||
|
- `OPENAI_API_KEY` (required): Your API key
|
||||||
|
- `OPENAI_BASE_URL` (optional): Custom provider URL (e.g., Ollama: `http://localhost:11434/v1`)
|
||||||
|
|
||||||
|
### Model Selection
|
||||||
|
|
||||||
|
1. Access the `/admin` page in your Minne instance
|
||||||
|
2. Select models for content processing and chat from your configured provider
|
||||||
|
3. **Content Processing Requirements**: The model must support structured outputs
|
||||||
|
4. **Embedding Dimensions**: Update this setting when changing embedding models (e.g., 1536 for `text-embedding-3-small`, 768 for `nomic-embed-text`)
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Current development focus:
|
||||||
|
|
||||||
|
- TUI frontend with system editor integration
|
||||||
|
- Enhanced reranking for improved retrieval recall
|
||||||
|
- Additional content type support
|
||||||
|
|
||||||
|
Feature requests and contributions are welcome!
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Development build
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Comprehensive linting
|
||||||
|
cargo clippy --workspace --all-targets --all-features
|
||||||
|
```
|
||||||
|
|
||||||
|
The codebase includes extensive unit tests. Integration tests and additional contributions are welcome.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
I've developed Minne primarily for my own use, but having been in the selfhosted space for a long time, and using the efforts by others, I thought I'd share with the community. Feature requests are welcome.
|
||||||
Feature requests and contributions welcome. See [Vision](docs/vision.md) for roadmap.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[AGPL-3.0](LICENSE)
|
Minne is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
@@ -62,7 +62,13 @@ DEFINE TABLE OVERWRITE conversation SCHEMAFULL;
|
|||||||
DEFINE TABLE OVERWRITE file SCHEMAFULL;
|
DEFINE TABLE OVERWRITE file SCHEMAFULL;
|
||||||
DEFINE TABLE OVERWRITE knowledge_entity SCHEMAFULL;
|
DEFINE TABLE OVERWRITE knowledge_entity SCHEMAFULL;
|
||||||
DEFINE TABLE OVERWRITE message SCHEMAFULL;
|
DEFINE TABLE OVERWRITE message SCHEMAFULL;
|
||||||
DEFINE TABLE OVERWRITE relates_to SCHEMAFULL;
|
DEFINE TABLE OVERWRITE relates_to SCHEMAFULL TYPE RELATION;
|
||||||
|
DEFINE FIELD IF NOT EXISTS in ON relates_to TYPE record<knowledge_entity>;
|
||||||
|
DEFINE FIELD IF NOT EXISTS out ON relates_to TYPE record<knowledge_entity>;
|
||||||
|
DEFINE FIELD IF NOT EXISTS metadata ON relates_to TYPE object;
|
||||||
|
DEFINE FIELD IF NOT EXISTS metadata.user_id ON relates_to TYPE string;
|
||||||
|
DEFINE FIELD IF NOT EXISTS metadata.source_id ON relates_to TYPE string;
|
||||||
|
DEFINE FIELD IF NOT EXISTS metadata.relationship_type ON relates_to TYPE string;
|
||||||
DEFINE TABLE OVERWRITE scratchpad SCHEMAFULL;
|
DEFINE TABLE OVERWRITE scratchpad SCHEMAFULL;
|
||||||
DEFINE TABLE OVERWRITE system_settings SCHEMAFULL;
|
DEFINE TABLE OVERWRITE system_settings SCHEMAFULL;
|
||||||
DEFINE TABLE OVERWRITE text_chunk SCHEMAFULL;
|
DEFINE TABLE OVERWRITE text_chunk SCHEMAFULL;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DEFINE FIELD IF NOT EXISTS theme ON user TYPE string DEFAULT "system";
|
||||||
@@ -253,11 +253,7 @@ async fn ensure_runtime_indexes_inner(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_index_status(
|
async fn get_index_status(db: &SurrealDbClient, index_name: &str, table: &str) -> Result<String> {
|
||||||
db: &SurrealDbClient,
|
|
||||||
index_name: &str,
|
|
||||||
table: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
let info_query = format!("INFO FOR INDEX {index_name} ON TABLE {table};");
|
let info_query = format!("INFO FOR INDEX {index_name} ON TABLE {table};");
|
||||||
let mut info_res = db
|
let mut info_res = db
|
||||||
.client
|
.client
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use anyhow::{anyhow, Result as AnyResult};
|
|||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
use futures::{StreamExt, TryStreamExt};
|
use futures::{StreamExt, TryStreamExt};
|
||||||
|
use object_store::aws::AmazonS3Builder;
|
||||||
use object_store::local::LocalFileSystem;
|
use object_store::local::LocalFileSystem;
|
||||||
use object_store::memory::InMemory;
|
use object_store::memory::InMemory;
|
||||||
use object_store::{path::Path as ObjPath, ObjectStore};
|
use object_store::{path::Path as ObjPath, ObjectStore};
|
||||||
@@ -234,6 +235,39 @@ async fn create_storage_backend(
|
|||||||
let store = InMemory::new();
|
let store = InMemory::new();
|
||||||
Ok((Arc::new(store), None))
|
Ok((Arc::new(store), None))
|
||||||
}
|
}
|
||||||
|
StorageKind::S3 => {
|
||||||
|
let bucket = cfg
|
||||||
|
.s3_bucket
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| object_store::Error::Generic {
|
||||||
|
store: "S3",
|
||||||
|
source: anyhow!("s3_bucket is required for S3 storage").into(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut builder = AmazonS3Builder::new()
|
||||||
|
.with_bucket_name(bucket)
|
||||||
|
.with_allow_http(true);
|
||||||
|
|
||||||
|
if let (Ok(key), Ok(secret)) = (
|
||||||
|
std::env::var("AWS_ACCESS_KEY_ID"),
|
||||||
|
std::env::var("AWS_SECRET_ACCESS_KEY"),
|
||||||
|
) {
|
||||||
|
builder = builder
|
||||||
|
.with_access_key_id(key)
|
||||||
|
.with_secret_access_key(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(endpoint) = &cfg.s3_endpoint {
|
||||||
|
builder = builder.with_endpoint(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(region) = &cfg.s3_region {
|
||||||
|
builder = builder.with_region(region);
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = builder.build()?;
|
||||||
|
Ok((Arc::new(store), None))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +324,29 @@ pub mod testing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a test configuration with S3 storage (MinIO).
|
||||||
|
///
|
||||||
|
/// This requires a running MinIO instance on localhost:9000.
|
||||||
|
pub fn test_config_s3() -> AppConfig {
|
||||||
|
AppConfig {
|
||||||
|
openai_api_key: "test".into(),
|
||||||
|
surrealdb_address: "test".into(),
|
||||||
|
surrealdb_username: "test".into(),
|
||||||
|
surrealdb_password: "test".into(),
|
||||||
|
surrealdb_namespace: "test".into(),
|
||||||
|
surrealdb_database: "test".into(),
|
||||||
|
data_dir: "/tmp/unused".into(),
|
||||||
|
http_port: 0,
|
||||||
|
openai_base_url: "..".into(),
|
||||||
|
storage: StorageKind::S3,
|
||||||
|
s3_bucket: Some("minne-tests".into()),
|
||||||
|
s3_endpoint: Some("http://localhost:9000".into()),
|
||||||
|
s3_region: Some("us-east-1".into()),
|
||||||
|
pdf_ingest_mode: PdfIngestMode::LlmFirst,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A specialized StorageManager for testing scenarios.
|
/// A specialized StorageManager for testing scenarios.
|
||||||
///
|
///
|
||||||
/// This provides automatic setup for memory storage with proper isolation
|
/// This provides automatic setup for memory storage with proper isolation
|
||||||
@@ -332,6 +389,26 @@ pub mod testing {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new TestStorageManager with S3 backend (MinIO).
|
||||||
|
///
|
||||||
|
/// This requires a running MinIO instance on localhost:9000 with
|
||||||
|
/// default credentials (minioadmin/minioadmin) and a 'minne-tests' bucket.
|
||||||
|
pub async fn new_s3() -> object_store::Result<Self> {
|
||||||
|
// Ensure credentials are set for MinIO
|
||||||
|
// We set these env vars for the process, which AmazonS3Builder will pick up
|
||||||
|
std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin");
|
||||||
|
std::env::set_var("AWS_SECRET_ACCESS_KEY", "minioadmin");
|
||||||
|
std::env::set_var("AWS_REGION", "us-east-1");
|
||||||
|
|
||||||
|
let cfg = test_config_s3();
|
||||||
|
let storage = StorageManager::new(&cfg).await?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
storage,
|
||||||
|
_temp_dir: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a TestStorageManager with custom configuration.
|
/// Create a TestStorageManager with custom configuration.
|
||||||
pub async fn with_config(cfg: &AppConfig) -> object_store::Result<Self> {
|
pub async fn with_config(cfg: &AppConfig) -> object_store::Result<Self> {
|
||||||
let storage = StorageManager::new(cfg).await?;
|
let storage = StorageManager::new(cfg).await?;
|
||||||
@@ -369,6 +446,14 @@ pub mod testing {
|
|||||||
self.storage.get(location).await
|
self.storage.get(location).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a streaming handle for test data.
|
||||||
|
pub async fn get_stream(
|
||||||
|
&self,
|
||||||
|
location: &str,
|
||||||
|
) -> object_store::Result<BoxStream<'static, object_store::Result<Bytes>>> {
|
||||||
|
self.storage.get_stream(location).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete test data below the specified prefix.
|
/// Delete test data below the specified prefix.
|
||||||
pub async fn delete_prefix(&self, prefix: &str) -> object_store::Result<()> {
|
pub async fn delete_prefix(&self, prefix: &str) -> object_store::Result<()> {
|
||||||
self.storage.delete_prefix(prefix).await
|
self.storage.delete_prefix(prefix).await
|
||||||
@@ -837,4 +922,117 @@ mod tests {
|
|||||||
// Verify it's using memory backend
|
// Verify it's using memory backend
|
||||||
assert_eq!(*test_storage.storage().backend_kind(), StorageKind::Memory);
|
assert_eq!(*test_storage.storage().backend_kind(), StorageKind::Memory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S3 Tests - Require MinIO on localhost:9000 with bucket 'minne-tests'
|
||||||
|
// These tests will fail if MinIO is not running or bucket doesn't exist.
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_storage_manager_s3_basic_operations() {
|
||||||
|
// Skip if S3 connection fails (e.g. no MinIO)
|
||||||
|
let Ok(storage) = testing::TestStorageManager::new_s3().await else {
|
||||||
|
eprintln!("Skipping S3 test (setup failed)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = format!("test-basic-{}", Uuid::new_v4());
|
||||||
|
let location = format!("{prefix}/file.txt");
|
||||||
|
let data = b"test data for S3";
|
||||||
|
|
||||||
|
// Test put
|
||||||
|
if let Err(e) = storage.put(&location, data).await {
|
||||||
|
eprintln!("Skipping S3 test (put failed - bucket missing?): {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test get
|
||||||
|
let retrieved = storage.get(&location).await.expect("get");
|
||||||
|
assert_eq!(retrieved.as_ref(), data);
|
||||||
|
|
||||||
|
// Test exists
|
||||||
|
assert!(storage.exists(&location).await.expect("exists"));
|
||||||
|
|
||||||
|
// Test delete
|
||||||
|
storage
|
||||||
|
.delete_prefix(&format!("{prefix}/"))
|
||||||
|
.await
|
||||||
|
.expect("delete");
|
||||||
|
assert!(!storage
|
||||||
|
.exists(&location)
|
||||||
|
.await
|
||||||
|
.expect("exists after delete"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_storage_manager_s3_list_operations() {
|
||||||
|
let Ok(storage) = testing::TestStorageManager::new_s3().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = format!("test-list-{}", Uuid::new_v4());
|
||||||
|
let files = vec![
|
||||||
|
(format!("{prefix}/file1.txt"), b"content1"),
|
||||||
|
(format!("{prefix}/file2.txt"), b"content2"),
|
||||||
|
(format!("{prefix}/sub/file3.txt"), b"content3"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (loc, data) in &files {
|
||||||
|
if storage.put(loc, *data).await.is_err() {
|
||||||
|
return; // Abort if put fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List with prefix
|
||||||
|
let list_prefix = format!("{prefix}/");
|
||||||
|
let items = storage.list(Some(&list_prefix)).await.expect("list");
|
||||||
|
assert_eq!(items.len(), 3);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
storage.delete_prefix(&list_prefix).await.expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_storage_manager_s3_stream_operations() {
|
||||||
|
let Ok(storage) = testing::TestStorageManager::new_s3().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = format!("test-stream-{}", Uuid::new_v4());
|
||||||
|
let location = format!("{prefix}/large.bin");
|
||||||
|
let content = vec![42u8; 1024 * 10]; // 10KB
|
||||||
|
|
||||||
|
if storage.put(&location, &content).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stream = storage.get_stream(&location).await.expect("get stream");
|
||||||
|
let mut collected = Vec::new();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
collected.extend_from_slice(&chunk.expect("chunk"));
|
||||||
|
}
|
||||||
|
assert_eq!(collected, content);
|
||||||
|
|
||||||
|
storage
|
||||||
|
.delete_prefix(&format!("{prefix}/"))
|
||||||
|
.await
|
||||||
|
.expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_storage_manager_s3_backend_kind() {
|
||||||
|
let Ok(storage) = testing::TestStorageManager::new_s3().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert_eq!(*storage.storage().backend_kind(), StorageKind::S3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_storage_manager_s3_error_handling() {
|
||||||
|
let Ok(storage) = testing::TestStorageManager::new_s3().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let location = format!("nonexistent-{}/file.txt", Uuid::new_v4());
|
||||||
|
assert!(storage.get(&location).await.is_err());
|
||||||
|
assert!(!storage.exists(&location).await.expect("exists check"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use bytes;
|
|||||||
use mime_guess::from_path;
|
use mime_guess::from_path;
|
||||||
use object_store::Error as ObjectStoreError;
|
use object_store::Error as ObjectStoreError;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{io::{BufReader, Read}, path::Path};
|
use std::{
|
||||||
|
io::{BufReader, Read},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::task;
|
use tokio::task;
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ impl KnowledgeEntity {
|
|||||||
source_id: &str,
|
source_id: &str,
|
||||||
db_client: &SurrealDbClient,
|
db_client: &SurrealDbClient,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
|
// Delete embeddings first, while we can still look them up via the entity's source_id
|
||||||
|
KnowledgeEntityEmbedding::delete_by_source_id(source_id, db_client).await?;
|
||||||
|
|
||||||
let query = format!(
|
let query = format!(
|
||||||
"DELETE {} WHERE source_id = '{}'",
|
"DELETE {} WHERE source_id = '{}'",
|
||||||
Self::table_name(),
|
Self::table_name(),
|
||||||
@@ -224,7 +227,7 @@ impl KnowledgeEntity {
|
|||||||
) -> Result<Vec<KnowledgeEntityVectorResult>, AppError> {
|
) -> Result<Vec<KnowledgeEntityVectorResult>, AppError> {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Row {
|
struct Row {
|
||||||
entity_id: KnowledgeEntity,
|
entity_id: Option<KnowledgeEntity>,
|
||||||
score: f32,
|
score: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,9 +260,11 @@ impl KnowledgeEntity {
|
|||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| KnowledgeEntityVectorResult {
|
.filter_map(|r| {
|
||||||
entity: r.entity_id,
|
r.entity_id.map(|entity| KnowledgeEntityVectorResult {
|
||||||
score: r.score,
|
entity,
|
||||||
|
score: r.score,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -460,7 +465,11 @@ impl KnowledgeEntity {
|
|||||||
|
|
||||||
for (i, entity) in all_entities.iter().enumerate() {
|
for (i, entity) in all_entities.iter().enumerate() {
|
||||||
if i > 0 && i % 100 == 0 {
|
if i > 0 && i % 100 == 0 {
|
||||||
info!(progress = i, total = total_entities, "Re-embedding progress");
|
info!(
|
||||||
|
progress = i,
|
||||||
|
total = total_entities,
|
||||||
|
"Re-embedding progress"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let embedding_input = format!(
|
let embedding_input = format!(
|
||||||
@@ -489,12 +498,12 @@ impl KnowledgeEntity {
|
|||||||
|
|
||||||
// Clear existing embeddings and index first to prevent SurrealDB panics and dimension conflicts.
|
// Clear existing embeddings and index first to prevent SurrealDB panics and dimension conflicts.
|
||||||
info!("Removing old index and clearing embeddings...");
|
info!("Removing old index and clearing embeddings...");
|
||||||
|
|
||||||
// Explicitly remove the index first. This prevents background HNSW maintenance from crashing
|
// Explicitly remove the index first. This prevents background HNSW maintenance from crashing
|
||||||
// when we delete/replace data, dealing with a known SurrealDB panic.
|
// when we delete/replace data, dealing with a known SurrealDB panic.
|
||||||
db.client
|
db.client
|
||||||
.query(format!(
|
.query(format!(
|
||||||
"REMOVE INDEX idx_embedding_knowledge_entity_embedding ON TABLE {};",
|
"REMOVE INDEX idx_embedding_knowledge_entity_embedding ON TABLE {};",
|
||||||
KnowledgeEntityEmbedding::table_name()
|
KnowledgeEntityEmbedding::table_name()
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
@@ -503,7 +512,10 @@ impl KnowledgeEntity {
|
|||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
db.client
|
db.client
|
||||||
.query(format!("DELETE FROM {};", KnowledgeEntityEmbedding::table_name()))
|
.query(format!(
|
||||||
|
"DELETE FROM {};",
|
||||||
|
KnowledgeEntityEmbedding::table_name()
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
.check()
|
.check()
|
||||||
@@ -907,4 +919,50 @@ mod tests {
|
|||||||
assert_eq!(results[0].entity.id, e2.id);
|
assert_eq!(results[0].entity.id, e2.id);
|
||||||
assert_eq!(results[1].entity.id, e1.id);
|
assert_eq!(results[1].entity.id, e1.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_vector_search_with_orphaned_embedding() {
|
||||||
|
let namespace = "test_ns_orphan";
|
||||||
|
let database = &Uuid::new_v4().to_string();
|
||||||
|
let db = SurrealDbClient::memory(namespace, database)
|
||||||
|
.await
|
||||||
|
.expect("Failed to start in-memory surrealdb");
|
||||||
|
db.apply_migrations()
|
||||||
|
.await
|
||||||
|
.expect("Failed to apply migrations");
|
||||||
|
|
||||||
|
KnowledgeEntityEmbedding::redefine_hnsw_index(&db, 3)
|
||||||
|
.await
|
||||||
|
.expect("Failed to redefine index length");
|
||||||
|
|
||||||
|
let user_id = "user".to_string();
|
||||||
|
let source_id = "src".to_string();
|
||||||
|
let entity = KnowledgeEntity::new(
|
||||||
|
source_id.clone(),
|
||||||
|
"orphan".to_string(),
|
||||||
|
"orphan desc".to_string(),
|
||||||
|
KnowledgeEntityType::Document,
|
||||||
|
None,
|
||||||
|
user_id.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
KnowledgeEntity::store_with_embedding(entity.clone(), vec![0.1, 0.2, 0.3], &db)
|
||||||
|
.await
|
||||||
|
.expect("store entity with embedding");
|
||||||
|
|
||||||
|
// Manually delete the entity to create an orphan
|
||||||
|
let query = format!("DELETE type::thing('knowledge_entity', '{}')", entity.id);
|
||||||
|
db.client.query(query).await.expect("delete entity");
|
||||||
|
|
||||||
|
// Now search
|
||||||
|
let results = KnowledgeEntity::vector_search(3, vec![0.1, 0.2, 0.3], &db, &user_id)
|
||||||
|
.await
|
||||||
|
.expect("search should succeed even with orphans");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
results.is_empty(),
|
||||||
|
"Should return empty result for orphan, got: {:?}",
|
||||||
|
results
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ impl KnowledgeRelationship {
|
|||||||
relationship_type = self.metadata.relationship_type.as_str()
|
relationship_type = self.metadata.relationship_type.as_str()
|
||||||
);
|
);
|
||||||
|
|
||||||
db_client.query(query).await?;
|
db_client.query(query).await?.check()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -99,9 +99,7 @@ impl KnowledgeRelationship {
|
|||||||
Err(AppError::NotFound(format!("Relationship {id} not found")))
|
Err(AppError::NotFound(format!("Relationship {id} not found")))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
db_client
|
db_client.query(format!("DELETE relates_to:`{id}`")).await?;
|
||||||
.query(format!("DELETE relates_to:`{id}`"))
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +159,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_store_relationship() {
|
async fn test_store_and_verify_by_source_id() {
|
||||||
// Setup in-memory database for testing
|
// Setup in-memory database for testing
|
||||||
let namespace = "test_ns";
|
let namespace = "test_ns";
|
||||||
let database = &Uuid::new_v4().to_string();
|
let database = &Uuid::new_v4().to_string();
|
||||||
@@ -169,6 +167,10 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("Failed to start in-memory surrealdb");
|
.expect("Failed to start in-memory surrealdb");
|
||||||
|
|
||||||
|
db.apply_migrations()
|
||||||
|
.await
|
||||||
|
.expect("Failed to apply migrations");
|
||||||
|
|
||||||
// Create two entities to relate
|
// Create two entities to relate
|
||||||
let entity1_id = create_test_entity("Entity 1", &db).await;
|
let entity1_id = create_test_entity("Entity 1", &db).await;
|
||||||
let entity2_id = create_test_entity("Entity 2", &db).await;
|
let entity2_id = create_test_entity("Entity 2", &db).await;
|
||||||
@@ -209,7 +211,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_delete_relationship_by_id() {
|
async fn test_store_and_delete_relationship() {
|
||||||
// Setup in-memory database for testing
|
// Setup in-memory database for testing
|
||||||
let namespace = "test_ns";
|
let namespace = "test_ns";
|
||||||
let database = &Uuid::new_v4().to_string();
|
let database = &Uuid::new_v4().to_string();
|
||||||
@@ -234,7 +236,7 @@ mod tests {
|
|||||||
relationship_type,
|
relationship_type,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store the relationship
|
// Store relationship
|
||||||
relationship
|
relationship
|
||||||
.store_relationship(&db)
|
.store_relationship(&db)
|
||||||
.await
|
.await
|
||||||
@@ -255,12 +257,12 @@ mod tests {
|
|||||||
"Relationship should exist before deletion"
|
"Relationship should exist before deletion"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete the relationship by ID
|
// Delete relationship by ID
|
||||||
KnowledgeRelationship::delete_relationship_by_id(&relationship.id, &user_id, &db)
|
KnowledgeRelationship::delete_relationship_by_id(&relationship.id, &user_id, &db)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to delete relationship by ID");
|
.expect("Failed to delete relationship by ID");
|
||||||
|
|
||||||
// Query to verify the relationship was deleted
|
// Query to verify relationship was deleted
|
||||||
let mut result = db
|
let mut result = db
|
||||||
.query(format!(
|
.query(format!(
|
||||||
"SELECT * FROM relates_to WHERE metadata.user_id = '{}' AND metadata.source_id = '{}'",
|
"SELECT * FROM relates_to WHERE metadata.user_id = '{}' AND metadata.source_id = '{}'",
|
||||||
@@ -270,7 +272,7 @@ mod tests {
|
|||||||
.expect("Query failed");
|
.expect("Query failed");
|
||||||
let results: Vec<KnowledgeRelationship> = result.take(0).unwrap_or_default();
|
let results: Vec<KnowledgeRelationship> = result.take(0).unwrap_or_default();
|
||||||
|
|
||||||
// Verify the relationship no longer exists
|
// Verify relationship no longer exists
|
||||||
assert!(results.is_empty(), "Relationship should be deleted");
|
assert!(results.is_empty(), "Relationship should be deleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +344,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_delete_relationships_by_source_id() {
|
async fn test_store_relationship_exists() {
|
||||||
// Setup in-memory database for testing
|
// Setup in-memory database for testing
|
||||||
let namespace = "test_ns";
|
let namespace = "test_ns";
|
||||||
let database = &Uuid::new_v4().to_string();
|
let database = &Uuid::new_v4().to_string();
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ macro_rules! stored_object {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$(#[$struct_attr])*
|
$(#[$struct_attr])*
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct $name {
|
pub struct $name {
|
||||||
#[serde(deserialize_with = "deserialize_flexible_id")]
|
#[serde(deserialize_with = "deserialize_flexible_id")]
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ impl TextChunk {
|
|||||||
source_id: &str,
|
source_id: &str,
|
||||||
db_client: &SurrealDbClient,
|
db_client: &SurrealDbClient,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
|
// Delete embeddings first
|
||||||
|
TextChunkEmbedding::delete_by_source_id(source_id, db_client).await?;
|
||||||
|
|
||||||
let query = format!(
|
let query = format!(
|
||||||
"DELETE {} WHERE source_id = '{}'",
|
"DELETE {} WHERE source_id = '{}'",
|
||||||
Self::table_name(),
|
Self::table_name(),
|
||||||
@@ -102,7 +105,7 @@ impl TextChunk {
|
|||||||
#[allow(clippy::missing_docs_in_private_items)]
|
#[allow(clippy::missing_docs_in_private_items)]
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Row {
|
struct Row {
|
||||||
chunk_id: TextChunk,
|
chunk_id: Option<TextChunk>,
|
||||||
score: f32,
|
score: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,9 +137,11 @@ impl TextChunk {
|
|||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| TextChunkSearchResult {
|
.filter_map(|r| {
|
||||||
chunk: r.chunk_id,
|
r.chunk_id.map(|chunk| TextChunkSearchResult {
|
||||||
score: r.score,
|
chunk,
|
||||||
|
score: r.score,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -352,12 +357,12 @@ impl TextChunk {
|
|||||||
// Generate all new embeddings in memory
|
// Generate all new embeddings in memory
|
||||||
let mut new_embeddings: HashMap<String, (Vec<f32>, String, String)> = HashMap::new();
|
let mut new_embeddings: HashMap<String, (Vec<f32>, String, String)> = HashMap::new();
|
||||||
info!("Generating new embeddings for all chunks...");
|
info!("Generating new embeddings for all chunks...");
|
||||||
|
|
||||||
for (i, chunk) in all_chunks.iter().enumerate() {
|
for (i, chunk) in all_chunks.iter().enumerate() {
|
||||||
if i > 0 && i % 100 == 0 {
|
if i > 0 && i % 100 == 0 {
|
||||||
info!(progress = i, total = total_chunks, "Re-embedding progress");
|
info!(progress = i, total = total_chunks, "Re-embedding progress");
|
||||||
}
|
}
|
||||||
|
|
||||||
let embedding = provider
|
let embedding = provider
|
||||||
.embed(&chunk.chunk)
|
.embed(&chunk.chunk)
|
||||||
.await
|
.await
|
||||||
@@ -381,12 +386,12 @@ impl TextChunk {
|
|||||||
|
|
||||||
// Clear existing embeddings and index first to prevent SurrealDB panics and dimension conflicts.
|
// Clear existing embeddings and index first to prevent SurrealDB panics and dimension conflicts.
|
||||||
info!("Removing old index and clearing embeddings...");
|
info!("Removing old index and clearing embeddings...");
|
||||||
|
|
||||||
// Explicitly remove the index first. This prevents background HNSW maintenance from crashing
|
// Explicitly remove the index first. This prevents background HNSW maintenance from crashing
|
||||||
// when we delete/replace data, dealing with a known SurrealDB panic.
|
// when we delete/replace data, dealing with a known SurrealDB panic.
|
||||||
db.client
|
db.client
|
||||||
.query(format!(
|
.query(format!(
|
||||||
"REMOVE INDEX idx_embedding_text_chunk_embedding ON TABLE {};",
|
"REMOVE INDEX idx_embedding_text_chunk_embedding ON TABLE {};",
|
||||||
TextChunkEmbedding::table_name()
|
TextChunkEmbedding::table_name()
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -102,44 +102,19 @@ impl TextChunkEmbedding {
|
|||||||
|
|
||||||
/// Delete all embeddings that belong to chunks with a given `source_id`
|
/// Delete all embeddings that belong to chunks with a given `source_id`
|
||||||
///
|
///
|
||||||
/// This uses a subquery to the `text_chunk` table:
|
/// This uses the denormalized `source_id` on the embedding table.
|
||||||
///
|
|
||||||
/// DELETE FROM text_chunk_embedding
|
|
||||||
/// WHERE chunk_id IN (SELECT id FROM text_chunk WHERE source_id = $source_id)
|
|
||||||
pub async fn delete_by_source_id(
|
pub async fn delete_by_source_id(
|
||||||
source_id: &str,
|
source_id: &str,
|
||||||
db: &SurrealDbClient,
|
db: &SurrealDbClient,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
#[allow(clippy::missing_docs_in_private_items)]
|
let query = format!(
|
||||||
#[derive(Deserialize)]
|
"DELETE FROM {} WHERE source_id = $source_id",
|
||||||
struct IdRow {
|
|
||||||
id: RecordId,
|
|
||||||
}
|
|
||||||
let ids_query = format!(
|
|
||||||
"SELECT id FROM {} WHERE source_id = $source_id",
|
|
||||||
TextChunk::table_name()
|
|
||||||
);
|
|
||||||
let mut res = db
|
|
||||||
.client
|
|
||||||
.query(ids_query)
|
|
||||||
.bind(("source_id", source_id.to_owned()))
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
let ids: Vec<IdRow> = res.take(0).map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
if ids.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let delete_query = format!(
|
|
||||||
"DELETE FROM {} WHERE chunk_id IN $chunk_ids",
|
|
||||||
Self::table_name()
|
Self::table_name()
|
||||||
);
|
);
|
||||||
|
|
||||||
db.client
|
db.client
|
||||||
.query(delete_query)
|
.query(query)
|
||||||
.bind((
|
.bind(("source_id", source_id.to_owned()))
|
||||||
"chunk_ids",
|
|
||||||
ids.into_iter().map(|row| row.id).collect::<Vec<_>>(),
|
|
||||||
))
|
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
.check()
|
.check()
|
||||||
|
|||||||
@@ -25,6 +25,56 @@ pub struct CategoryResponse {
|
|||||||
category: String,
|
category: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Supported UI themes.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Theme {
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
WarmPaper,
|
||||||
|
ObsidianPrism,
|
||||||
|
#[default]
|
||||||
|
System,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Theme {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"light" => Ok(Self::Light),
|
||||||
|
"dark" => Ok(Self::Dark),
|
||||||
|
"warm-paper" => Ok(Self::WarmPaper),
|
||||||
|
"obsidian-prism" => Ok(Self::ObsidianPrism),
|
||||||
|
"system" => Ok(Self::System),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Light => "light",
|
||||||
|
Self::Dark => "dark",
|
||||||
|
Self::WarmPaper => "warm-paper",
|
||||||
|
Self::ObsidianPrism => "obsidian-prism",
|
||||||
|
Self::System => "system",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the theme that should be initially applied.
|
||||||
|
/// For "system", defaults to "light".
|
||||||
|
pub fn initial_theme(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::System => "light",
|
||||||
|
other => other.as_str(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stored_object!(
|
stored_object!(
|
||||||
#[allow(clippy::unsafe_derive_deserialize)]
|
#[allow(clippy::unsafe_derive_deserialize)]
|
||||||
User, "user", {
|
User, "user", {
|
||||||
@@ -34,9 +84,21 @@ stored_object!(
|
|||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
admin: bool,
|
admin: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
timezone: String
|
timezone: String,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_theme_or_default")]
|
||||||
|
theme: Theme
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fn deserialize_theme_or_default<'de, D>(deserializer: D) -> Result<Theme, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let raw = Option::<String>::deserialize(deserializer)?;
|
||||||
|
Ok(raw
|
||||||
|
.and_then(|value| Theme::from_str(value.as_str()).ok())
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Authentication<User, String, Surreal<Any>> for User {
|
impl Authentication<User, String, Surreal<Any>> for User {
|
||||||
async fn load_user(userid: String, db: Option<&Surreal<Any>>) -> Result<User, anyhow::Error> {
|
async fn load_user(userid: String, db: Option<&Surreal<Any>>) -> Result<User, anyhow::Error> {
|
||||||
@@ -70,6 +132,11 @@ fn validate_timezone(input: &str) -> String {
|
|||||||
"UTC".to_owned()
|
"UTC".to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensures a theme string is valid, defaulting to "system" when invalid.
|
||||||
|
fn validate_theme(input: &str) -> Theme {
|
||||||
|
Theme::from_str(input).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct DashboardStats {
|
pub struct DashboardStats {
|
||||||
pub total_documents: i64,
|
pub total_documents: i64,
|
||||||
@@ -168,6 +235,7 @@ impl User {
|
|||||||
password: String,
|
password: String,
|
||||||
db: &SurrealDbClient,
|
db: &SurrealDbClient,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
|
theme: String,
|
||||||
) -> Result<Self, AppError> {
|
) -> Result<Self, AppError> {
|
||||||
// verify that the application allows new creations
|
// verify that the application allows new creations
|
||||||
let systemsettings = SystemSettings::get_current(db).await?;
|
let systemsettings = SystemSettings::get_current(db).await?;
|
||||||
@@ -176,6 +244,7 @@ impl User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let validated_tz = validate_timezone(&timezone);
|
let validated_tz = validate_timezone(&timezone);
|
||||||
|
let validated_theme = validate_theme(&theme);
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
@@ -190,7 +259,8 @@ impl User {
|
|||||||
anonymous = false,
|
anonymous = false,
|
||||||
created_at = $created_at,
|
created_at = $created_at,
|
||||||
updated_at = $updated_at,
|
updated_at = $updated_at,
|
||||||
timezone = $timezone",
|
timezone = $timezone,
|
||||||
|
theme = $theme",
|
||||||
)
|
)
|
||||||
.bind(("table", "user"))
|
.bind(("table", "user"))
|
||||||
.bind(("id", id))
|
.bind(("id", id))
|
||||||
@@ -199,6 +269,7 @@ impl User {
|
|||||||
.bind(("created_at", surrealdb::Datetime::from(now)))
|
.bind(("created_at", surrealdb::Datetime::from(now)))
|
||||||
.bind(("updated_at", surrealdb::Datetime::from(now)))
|
.bind(("updated_at", surrealdb::Datetime::from(now)))
|
||||||
.bind(("timezone", validated_tz))
|
.bind(("timezone", validated_tz))
|
||||||
|
.bind(("theme", validated_theme.as_str()))
|
||||||
.await?
|
.await?
|
||||||
.take(1)?;
|
.take(1)?;
|
||||||
|
|
||||||
@@ -468,6 +539,19 @@ impl User {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_theme(
|
||||||
|
user_id: &str,
|
||||||
|
theme: &str,
|
||||||
|
db: &SurrealDbClient,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let validated_theme = validate_theme(theme);
|
||||||
|
db.query("UPDATE type::thing('user', $user_id) SET theme = $theme")
|
||||||
|
.bind(("user_id", user_id.to_string()))
|
||||||
|
.bind(("theme", validated_theme.as_str()))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_user_categories(
|
pub async fn get_user_categories(
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
db: &SurrealDbClient,
|
db: &SurrealDbClient,
|
||||||
@@ -674,6 +758,7 @@ mod tests {
|
|||||||
password.to_string(),
|
password.to_string(),
|
||||||
&db,
|
&db,
|
||||||
timezone.to_string(),
|
timezone.to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -711,6 +796,7 @@ mod tests {
|
|||||||
password.to_string(),
|
password.to_string(),
|
||||||
&db,
|
&db,
|
||||||
"UTC".to_string(),
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -858,6 +944,7 @@ mod tests {
|
|||||||
password.to_string(),
|
password.to_string(),
|
||||||
&db,
|
&db,
|
||||||
"UTC".to_string(),
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -892,6 +979,7 @@ mod tests {
|
|||||||
password.to_string(),
|
password.to_string(),
|
||||||
&db,
|
&db,
|
||||||
"UTC".to_string(),
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -944,6 +1032,42 @@ mod tests {
|
|||||||
assert!(not_found.is_none());
|
assert!(not_found.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_set_api_key_with_none_theme() {
|
||||||
|
let db = setup_test_db().await;
|
||||||
|
|
||||||
|
let user = User::create_new(
|
||||||
|
"legacy_theme@example.com".to_string(),
|
||||||
|
"apikey_password".to_string(),
|
||||||
|
&db,
|
||||||
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create user");
|
||||||
|
|
||||||
|
db.client
|
||||||
|
.query("UPDATE type::thing('user', $id) SET theme = NONE")
|
||||||
|
.bind(("id", user.id.clone()))
|
||||||
|
.await
|
||||||
|
.expect("Failed to set user theme to NONE");
|
||||||
|
|
||||||
|
let api_key = User::set_api_key(&user.id, &db)
|
||||||
|
.await
|
||||||
|
.expect("set_api_key should tolerate NONE theme");
|
||||||
|
|
||||||
|
assert!(api_key.starts_with("sk_"));
|
||||||
|
|
||||||
|
let updated_user = db
|
||||||
|
.get_item::<User>(&user.id)
|
||||||
|
.await
|
||||||
|
.expect("Failed to retrieve user")
|
||||||
|
.expect("User should still exist");
|
||||||
|
|
||||||
|
assert_eq!(updated_user.theme, Theme::System);
|
||||||
|
assert_eq!(updated_user.api_key, Some(api_key));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_password_update() {
|
async fn test_password_update() {
|
||||||
// Setup test database
|
// Setup test database
|
||||||
@@ -959,6 +1083,7 @@ mod tests {
|
|||||||
old_password.to_string(),
|
old_password.to_string(),
|
||||||
&db,
|
&db,
|
||||||
"UTC".to_string(),
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -1006,6 +1131,7 @@ mod tests {
|
|||||||
"password".to_string(),
|
"password".to_string(),
|
||||||
&db,
|
&db,
|
||||||
"UTC".to_string(),
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -1116,4 +1242,51 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_validate_theme() {
|
||||||
|
assert_eq!(validate_theme("light"), Theme::Light);
|
||||||
|
assert_eq!(validate_theme("dark"), Theme::Dark);
|
||||||
|
assert_eq!(validate_theme("system"), Theme::System);
|
||||||
|
assert_eq!(validate_theme("invalid"), Theme::System);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_theme_update() {
|
||||||
|
let db = setup_test_db().await;
|
||||||
|
let email = "theme_test@example.com";
|
||||||
|
let user = User::create_new(
|
||||||
|
email.to_string(),
|
||||||
|
"password".to_string(),
|
||||||
|
&db,
|
||||||
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create user");
|
||||||
|
|
||||||
|
assert_eq!(user.theme, Theme::System);
|
||||||
|
|
||||||
|
User::update_theme(&user.id, "dark", &db)
|
||||||
|
.await
|
||||||
|
.expect("update theme");
|
||||||
|
|
||||||
|
let updated = db
|
||||||
|
.get_item::<User>(&user.id)
|
||||||
|
.await
|
||||||
|
.expect("get user")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(updated.theme, Theme::Dark);
|
||||||
|
|
||||||
|
// Invalid theme should default to system (but update_theme calls validate_theme)
|
||||||
|
User::update_theme(&user.id, "invalid", &db)
|
||||||
|
.await
|
||||||
|
.expect("update theme invalid");
|
||||||
|
let updated2 = db
|
||||||
|
.get_item::<User>(&user.id)
|
||||||
|
.await
|
||||||
|
.expect("get user")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(updated2.theme, Theme::System);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub enum EmbeddingBackend {
|
|||||||
pub enum StorageKind {
|
pub enum StorageKind {
|
||||||
Local,
|
Local,
|
||||||
Memory,
|
Memory,
|
||||||
|
S3,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default storage backend when none is configured.
|
/// Default storage backend when none is configured.
|
||||||
@@ -27,6 +28,10 @@ fn default_storage_kind() -> StorageKind {
|
|||||||
StorageKind::Local
|
StorageKind::Local
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_s3_region() -> Option<String> {
|
||||||
|
Some("us-east-1".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Selects the strategy used for PDF ingestion.
|
/// Selects the strategy used for PDF ingestion.
|
||||||
#[derive(Clone, Deserialize, Debug)]
|
#[derive(Clone, Deserialize, Debug)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
@@ -59,6 +64,12 @@ pub struct AppConfig {
|
|||||||
pub openai_base_url: String,
|
pub openai_base_url: String,
|
||||||
#[serde(default = "default_storage_kind")]
|
#[serde(default = "default_storage_kind")]
|
||||||
pub storage: StorageKind,
|
pub storage: StorageKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub s3_bucket: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub s3_endpoint: Option<String>,
|
||||||
|
#[serde(default = "default_s3_region")]
|
||||||
|
pub s3_region: Option<String>,
|
||||||
#[serde(default = "default_pdf_ingest_mode")]
|
#[serde(default = "default_pdf_ingest_mode")]
|
||||||
pub pdf_ingest_mode: PdfIngestMode,
|
pub pdf_ingest_mode: PdfIngestMode,
|
||||||
#[serde(default = "default_reranking_enabled")]
|
#[serde(default = "default_reranking_enabled")]
|
||||||
@@ -135,6 +146,9 @@ impl Default for AppConfig {
|
|||||||
http_port: 0,
|
http_port: 0,
|
||||||
openai_base_url: default_base_url(),
|
openai_base_url: default_base_url(),
|
||||||
storage: default_storage_kind(),
|
storage: default_storage_kind(),
|
||||||
|
s3_bucket: None,
|
||||||
|
s3_endpoint: None,
|
||||||
|
s3_region: default_s3_region(),
|
||||||
pdf_ingest_mode: default_pdf_ingest_mode(),
|
pdf_ingest_mode: default_pdf_ingest_mode(),
|
||||||
reranking_enabled: default_reranking_enabled(),
|
reranking_enabled: default_reranking_enabled(),
|
||||||
reranking_pool_size: None,
|
reranking_pool_size: None,
|
||||||
|
|||||||
@@ -250,9 +250,8 @@ impl EmbeddingProvider {
|
|||||||
|
|
||||||
match config.embedding_backend {
|
match config.embedding_backend {
|
||||||
EmbeddingBackend::OpenAI => {
|
EmbeddingBackend::OpenAI => {
|
||||||
let client = openai_client.ok_or_else(|| {
|
let client = openai_client
|
||||||
anyhow!("OpenAI embedding backend requires an OpenAI client")
|
.ok_or_else(|| anyhow!("OpenAI embedding backend requires an OpenAI client"))?;
|
||||||
})?;
|
|
||||||
// Use defaults that match SystemSettings initial values
|
// Use defaults that match SystemSettings initial values
|
||||||
Self::new_openai(client, "text-embedding-3-small".to_string(), 1536)
|
Self::new_openai(client, "text-embedding-3-small".to_string(), 1536)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,19 +21,49 @@ pub enum TemplateEngine {
|
|||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! create_template_engine {
|
macro_rules! create_template_engine {
|
||||||
// Macro takes the relative path to the templates dir as input
|
// Single path argument
|
||||||
($relative_path:expr) => {{
|
($relative_path:expr) => {
|
||||||
|
$crate::create_template_engine!($relative_path, Option::<&str>::None)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Path + Fallback argument
|
||||||
|
($relative_path:expr, $fallback_path:expr) => {{
|
||||||
// Code for debug builds (AutoReload)
|
// Code for debug builds (AutoReload)
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
{
|
{
|
||||||
// These lines execute in the CALLING crate's context
|
// These lines execute in the CALLING crate's context
|
||||||
let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||||
let template_path = crate_dir.join($relative_path);
|
let template_path = crate_dir.join($relative_path);
|
||||||
|
let fallback_path = $fallback_path.map(|p| crate_dir.join(p));
|
||||||
|
|
||||||
let reloader = $crate::utils::template_engine::AutoReloader::new(move |notifier| {
|
let reloader = $crate::utils::template_engine::AutoReloader::new(move |notifier| {
|
||||||
let mut env = $crate::utils::template_engine::Environment::new();
|
let mut env = $crate::utils::template_engine::Environment::new();
|
||||||
env.set_loader($crate::utils::template_engine::path_loader(&template_path));
|
|
||||||
|
let loader_primary = $crate::utils::template_engine::path_loader(&template_path);
|
||||||
|
|
||||||
|
// Clone fallback_path for the closure
|
||||||
|
let fallback = fallback_path.clone();
|
||||||
|
|
||||||
|
env.set_loader(move |name| match loader_primary(name) {
|
||||||
|
Ok(Some(tmpl)) => Ok(Some(tmpl)),
|
||||||
|
Ok(None) => {
|
||||||
|
if let Some(ref fb_path) = fallback {
|
||||||
|
let loader_fallback =
|
||||||
|
$crate::utils::template_engine::path_loader(fb_path);
|
||||||
|
loader_fallback(name)
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
});
|
||||||
|
|
||||||
notifier.set_fast_reload(true);
|
notifier.set_fast_reload(true);
|
||||||
notifier.watch_path(&template_path, true);
|
notifier.watch_path(&template_path, true);
|
||||||
|
if let Some(ref fb) = fallback_path {
|
||||||
|
notifier.watch_path(fb, true);
|
||||||
|
}
|
||||||
|
|
||||||
// Add contrib filters/functions
|
// Add contrib filters/functions
|
||||||
$crate::utils::template_engine::minijinja_contrib::add_to_environment(&mut env);
|
$crate::utils::template_engine::minijinja_contrib::add_to_environment(&mut env);
|
||||||
Ok(env)
|
Ok(env)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
pkgs.cargo-xwin
|
pkgs.cargo-xwin
|
||||||
pkgs.clang
|
pkgs.clang
|
||||||
pkgs.onnxruntime
|
pkgs.onnxruntime
|
||||||
|
pkgs.cargo-watch
|
||||||
|
pkgs.tailwindcss_4
|
||||||
];
|
];
|
||||||
|
|
||||||
languages.rust = {
|
languages.rust = {
|
||||||
@@ -31,5 +33,7 @@
|
|||||||
|
|
||||||
processes = {
|
processes = {
|
||||||
surreal_db.exec = "docker run --rm --pull always -p 8000:8000 --net=host --user $(id -u) -v $(pwd)/database:/database surrealdb/surrealdb:latest-dev start rocksdb:/database/database.db --user root_user --pass root_password";
|
surreal_db.exec = "docker run --rm --pull always -p 8000:8000 --net=host --user $(id -u) -v $(pwd)/database:/database surrealdb/surrealdb:latest-dev start rocksdb:/database/database.db --user root_user --pass root_password";
|
||||||
|
server.exec = "cargo watch -x 'run --bin main'";
|
||||||
|
tailwind.exec = "cd html-router && npm run tailwind";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ include = ["lib"]
|
|||||||
# The installers to generate for each app
|
# The installers to generate for each app
|
||||||
installers = []
|
installers = []
|
||||||
# Target platforms to build apps for (Rust target-triple syntax)
|
# Target platforms to build apps for (Rust target-triple syntax)
|
||||||
targets = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
|
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
|
||||||
# Skip checking whether the specified configuration files are up to date
|
# Skip checking whether the specified configuration files are up to date
|
||||||
allow-dirty = ["ci"]
|
allow-dirty = ["ci"]
|
||||||
|
|
||||||
[dist.github-custom-runners]
|
[dist.github-custom-runners]
|
||||||
|
aarch64-apple-darwin = "macos-latest"
|
||||||
|
x86_64-apple-darwin = "macos-15-intel"
|
||||||
x86_64-unknown-linux-gnu = "ubuntu-22.04"
|
x86_64-unknown-linux-gnu = "ubuntu-22.04"
|
||||||
x86_64-unknown-linux-musl = "ubuntu-22.04"
|
x86_64-unknown-linux-musl = "ubuntu-22.04"
|
||||||
x86_64-pc-windows-msvc = "windows-latest"
|
x86_64-pc-windows-msvc = "windows-latest"
|
||||||
|
|||||||
@@ -22,14 +22,26 @@ Minne can be configured via environment variables or a `config.yaml` file. Envir
|
|||||||
| `DATA_DIR` | Local data directory | `./data` |
|
| `DATA_DIR` | Local data directory | `./data` |
|
||||||
| `OPENAI_BASE_URL` | Custom AI provider URL | OpenAI default |
|
| `OPENAI_BASE_URL` | Custom AI provider URL | OpenAI default |
|
||||||
| `RUST_LOG` | Logging level | `info` |
|
| `RUST_LOG` | Logging level | `info` |
|
||||||
| `STORAGE` | Storage backend (`local`, `memory`) | `local` |
|
| `STORAGE` | Storage backend (`local`, `memory`, `s3`) | `local` |
|
||||||
| `PDF_INGEST_MODE` | PDF ingestion strategy (`classic`, `llm-first`) | `llm-first` |
|
| `PDF_INGEST_MODE` | PDF ingestion strategy (`classic`, `llm-first`) | `llm-first` |
|
||||||
| `RETRIEVAL_STRATEGY` | Default retrieval strategy | - |
|
| `RETRIEVAL_STRATEGY` | Default retrieval strategy | - |
|
||||||
| `EMBEDDING_BACKEND` | Embedding provider (`openai`, `fastembed`, `hashed`) | `fastembed` |
|
| `EMBEDDING_BACKEND` | Embedding provider (`openai`, `fastembed`) | `fastembed` |
|
||||||
| `FASTEMBED_CACHE_DIR` | Model cache directory | `<data_dir>/fastembed` |
|
| `FASTEMBED_CACHE_DIR` | Model cache directory | `<data_dir>/fastembed` |
|
||||||
| `FASTEMBED_SHOW_DOWNLOAD_PROGRESS` | Show progress bar for model downloads | `false` |
|
| `FASTEMBED_SHOW_DOWNLOAD_PROGRESS` | Show progress bar for model downloads | `false` |
|
||||||
| `FASTEMBED_MAX_LENGTH` | Max sequence length for FastEmbed models | - |
|
| `FASTEMBED_MAX_LENGTH` | Max sequence length for FastEmbed models | - |
|
||||||
|
|
||||||
|
### S3 Storage (Optional)
|
||||||
|
|
||||||
|
Used when `STORAGE` is set to `s3`.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `S3_BUCKET` | S3 bucket name | - |
|
||||||
|
| `S3_ENDPOINT` | Custom endpoint (e.g. MinIO) | AWS default |
|
||||||
|
| `S3_REGION` | AWS Region | `us-east-1` |
|
||||||
|
| `AWS_ACCESS_KEY_ID` | Access key | - |
|
||||||
|
| `AWS_SECRET_ACCESS_KEY` | Secret key | - |
|
||||||
|
|
||||||
### Reranking (Optional)
|
### Reranking (Optional)
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
@@ -54,6 +66,10 @@ http_port: 3000
|
|||||||
|
|
||||||
# New settings
|
# New settings
|
||||||
storage: "local"
|
storage: "local"
|
||||||
|
# storage: "s3"
|
||||||
|
# s3_bucket: "my-bucket"
|
||||||
|
# s3_endpoint: "http://localhost:9000" # Optional, for MinIO etc.
|
||||||
|
# s3_region: "us-east-1"
|
||||||
pdf_ingest_mode: "llm-first"
|
pdf_ingest_mode: "llm-first"
|
||||||
embedding_backend: "fastembed"
|
embedding_backend: "fastembed"
|
||||||
|
|
||||||
|
|||||||
@@ -893,158 +893,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn seeds_manifest_with_transactional_batches() {
|
|
||||||
let namespace = "test_ns";
|
|
||||||
let database = Uuid::new_v4().to_string();
|
|
||||||
let db = SurrealDbClient::memory(namespace, &database)
|
|
||||||
.await
|
|
||||||
.expect("memory db");
|
|
||||||
db.apply_migrations()
|
|
||||||
.await
|
|
||||||
.expect("apply migrations for memory db");
|
|
||||||
|
|
||||||
let manifest = build_manifest();
|
|
||||||
seed_manifest_into_db(&db, &manifest)
|
|
||||||
.await
|
|
||||||
.expect("manifest seed should succeed");
|
|
||||||
|
|
||||||
let text_contents: Vec<TextContent> = db
|
|
||||||
.client
|
|
||||||
.query(format!("SELECT * FROM {};", TextContent::table_name()))
|
|
||||||
.await
|
|
||||||
.expect("select text_content")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
assert_eq!(text_contents.len(), 1);
|
|
||||||
|
|
||||||
let entities: Vec<KnowledgeEntity> = db
|
|
||||||
.client
|
|
||||||
.query(format!("SELECT * FROM {};", KnowledgeEntity::table_name()))
|
|
||||||
.await
|
|
||||||
.expect("select knowledge_entity")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
assert_eq!(entities.len(), 1);
|
|
||||||
|
|
||||||
let chunks: Vec<TextChunk> = db
|
|
||||||
.client
|
|
||||||
.query(format!("SELECT * FROM {};", TextChunk::table_name()))
|
|
||||||
.await
|
|
||||||
.expect("select text_chunk")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
assert_eq!(chunks.len(), 1);
|
|
||||||
|
|
||||||
let relationships: Vec<KnowledgeRelationship> = db
|
|
||||||
.client
|
|
||||||
.query("SELECT * FROM relates_to;")
|
|
||||||
.await
|
|
||||||
.expect("select relates_to")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
assert_eq!(relationships.len(), 1);
|
|
||||||
|
|
||||||
let entity_embeddings: Vec<KnowledgeEntityEmbedding> = db
|
|
||||||
.client
|
|
||||||
.query(format!(
|
|
||||||
"SELECT * FROM {};",
|
|
||||||
KnowledgeEntityEmbedding::table_name()
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("select knowledge_entity_embedding")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
assert_eq!(entity_embeddings.len(), 1);
|
|
||||||
|
|
||||||
let chunk_embeddings: Vec<TextChunkEmbedding> = db
|
|
||||||
.client
|
|
||||||
.query(format!(
|
|
||||||
"SELECT * FROM {};",
|
|
||||||
TextChunkEmbedding::table_name()
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("select text_chunk_embedding")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
assert_eq!(chunk_embeddings.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rolls_back_when_embeddings_mismatch_index_dimension() {
|
|
||||||
let namespace = "test_ns_rollback";
|
|
||||||
let database = Uuid::new_v4().to_string();
|
|
||||||
let db = SurrealDbClient::memory(namespace, &database)
|
|
||||||
.await
|
|
||||||
.expect("memory db");
|
|
||||||
db.apply_migrations()
|
|
||||||
.await
|
|
||||||
.expect("apply migrations for memory db");
|
|
||||||
|
|
||||||
let manifest = build_manifest();
|
|
||||||
let result = seed_manifest_into_db(&db, &manifest).await;
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"seeding should succeed even if embedding dimensions differ from default index"
|
|
||||||
);
|
|
||||||
|
|
||||||
let text_contents: Vec<TextContent> = db
|
|
||||||
.client
|
|
||||||
.query(format!("SELECT * FROM {};", TextContent::table_name()))
|
|
||||||
.await
|
|
||||||
.expect("select text_content")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let entities: Vec<KnowledgeEntity> = db
|
|
||||||
.client
|
|
||||||
.query(format!("SELECT * FROM {};", KnowledgeEntity::table_name()))
|
|
||||||
.await
|
|
||||||
.expect("select knowledge_entity")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let chunks: Vec<TextChunk> = db
|
|
||||||
.client
|
|
||||||
.query(format!("SELECT * FROM {};", TextChunk::table_name()))
|
|
||||||
.await
|
|
||||||
.expect("select text_chunk")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let relationships: Vec<KnowledgeRelationship> = db
|
|
||||||
.client
|
|
||||||
.query("SELECT * FROM relates_to;")
|
|
||||||
.await
|
|
||||||
.expect("select relates_to")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let entity_embeddings: Vec<KnowledgeEntityEmbedding> = db
|
|
||||||
.client
|
|
||||||
.query(format!(
|
|
||||||
"SELECT * FROM {};",
|
|
||||||
KnowledgeEntityEmbedding::table_name()
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("select knowledge_entity_embedding")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let chunk_embeddings: Vec<TextChunkEmbedding> = db
|
|
||||||
.client
|
|
||||||
.query(format!(
|
|
||||||
"SELECT * FROM {};",
|
|
||||||
TextChunkEmbedding::table_name()
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("select text_chunk_embedding")
|
|
||||||
.take(0)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
assert_eq!(text_contents.len(), 1);
|
|
||||||
assert_eq!(entities.len(), 1);
|
|
||||||
assert_eq!(chunks.len(), 1);
|
|
||||||
assert_eq!(relationships.len(), 1);
|
|
||||||
assert_eq!(entity_embeddings.len(), 1);
|
|
||||||
assert_eq!(chunk_embeddings.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn window_manifest_trims_questions_and_negatives() {
|
fn window_manifest_trims_questions_and_negatives() {
|
||||||
let manifest = build_manifest();
|
let manifest = build_manifest();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::{
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use common::storage::{db::SurrealDbClient, types::text_chunk::TextChunk};
|
use common::storage::{db::SurrealDbClient, types::text_chunk::TextChunk};
|
||||||
|
|
||||||
use crate::{args::Config, eval::connect_eval_db, corpus, snapshot::DbSnapshotState};
|
use crate::{args::Config, corpus, eval::connect_eval_db, snapshot::DbSnapshotState};
|
||||||
|
|
||||||
pub async fn inspect_question(config: &Config) -> Result<()> {
|
pub async fn inspect_question(config: &Config) -> Result<()> {
|
||||||
let question_id = config
|
let question_id = config
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use common::storage::{db::SurrealDbClient, types::user::User, types::StoredObject};
|
use common::storage::{
|
||||||
|
db::SurrealDbClient,
|
||||||
|
types::user::{Theme, User},
|
||||||
|
types::StoredObject,
|
||||||
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
@@ -212,6 +216,7 @@ pub(crate) async fn ensure_eval_user(db: &SurrealDbClient) -> Result<User> {
|
|||||||
api_key: None,
|
api_key: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
timezone: "UTC".to_string(),
|
timezone: "UTC".to_string(),
|
||||||
|
theme: Theme::System,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(existing) = db.get_item::<User>(user.get_id()).await? {
|
if let Some(existing) = db.get_item::<User>(user.get_id()).await? {
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ use retrieval_pipeline::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
args::Config,
|
args::Config,
|
||||||
cache::EmbeddingCache,
|
cache::EmbeddingCache,
|
||||||
|
corpus,
|
||||||
datasets::ConvertedDataset,
|
datasets::ConvertedDataset,
|
||||||
eval::{CaseDiagnostics, CaseSummary, EvaluationStageTimings, EvaluationSummary, SeededCase},
|
eval::{CaseDiagnostics, CaseSummary, EvaluationStageTimings, EvaluationSummary, SeededCase},
|
||||||
corpus, slice, snapshot,
|
slice, snapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) struct EvaluationContext<'a> {
|
pub(super) struct EvaluationContext<'a> {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::time::Instant;
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{eval::can_reuse_namespace, corpus, slice, snapshot};
|
use crate::{corpus, eval::can_reuse_namespace, slice, snapshot};
|
||||||
|
|
||||||
use super::super::{
|
use super::super::{
|
||||||
context::{EvalStage, EvaluationContext},
|
context::{EvalStage, EvaluationContext},
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ use common::storage::types::system_settings::SystemSettings;
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
corpus,
|
||||||
db_helpers::{recreate_indexes, remove_all_indexes, reset_namespace},
|
db_helpers::{recreate_indexes, remove_all_indexes, reset_namespace},
|
||||||
eval::{
|
eval::{
|
||||||
can_reuse_namespace, cases_from_manifest, enforce_system_settings, ensure_eval_user,
|
can_reuse_namespace, cases_from_manifest, enforce_system_settings, ensure_eval_user,
|
||||||
record_namespace_state, warm_hnsw_cache,
|
record_namespace_state, warm_hnsw_cache,
|
||||||
},
|
},
|
||||||
corpus,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::super::{
|
use super::super::{
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ pub(crate) async fn prepare_slice(
|
|||||||
.database
|
.database
|
||||||
.db_namespace
|
.db_namespace
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| default_namespace(ctx.dataset().metadata.id.as_str(), ctx.config().limit));
|
.unwrap_or_else(|| {
|
||||||
|
default_namespace(ctx.dataset().metadata.id.as_str(), ctx.config().limit)
|
||||||
|
});
|
||||||
ctx.database = ctx
|
ctx.database = ctx
|
||||||
.config()
|
.config()
|
||||||
.database
|
.database
|
||||||
|
|||||||
@@ -69,26 +69,41 @@
|
|||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--color-base-100: oklch(22% 0.015 255);
|
|
||||||
--color-base-200: oklch(18% 0.014 253);
|
/* --- Canvas: Dark Warm Grey (Matches Light Mode's hue 90) --- */
|
||||||
--color-base-300: oklch(14% 0.012 251);
|
--color-base-100: oklch(18% 0.01 90);
|
||||||
--color-base-content: oklch(97.2% 0.02 255);
|
--color-base-200: oklch(15% 0.01 90);
|
||||||
--color-primary: oklch(58% 0.233 277.12);
|
--color-base-300: oklch(12% 0.01 90);
|
||||||
--color-primary-content: oklch(96% 0.018 272.31);
|
--color-base-content: oklch(96% 0.01 90);
|
||||||
--color-secondary: oklch(65% 0.241 354.31);
|
|
||||||
--color-secondary-content: oklch(94% 0.028 342.26);
|
/* --- Primary: Vibrant Indigo (Light Mode Hue 265, boosted for dark) --- */
|
||||||
--color-accent: oklch(78% 0.22 80);
|
--color-primary: oklch(65% 0.22 265);
|
||||||
--color-accent-content: oklch(20% 0.035 80);
|
--color-primary-content: oklch(98% 0.01 265);
|
||||||
--color-neutral: oklch(26% 0.02 255);
|
|
||||||
--color-neutral-content: oklch(97% 0.03 255);
|
/* --- Secondary: Deep Indigo (Similar to Light Mode Primary) --- */
|
||||||
--color-info: oklch(74% 0.16 232.66);
|
--color-secondary: oklch(45% 0.18 265);
|
||||||
--color-info-content: oklch(29% 0.066 243.16);
|
--color-secondary-content: oklch(98% 0.01 265);
|
||||||
--color-success: oklch(76% 0.177 163.22);
|
|
||||||
--color-success-content: oklch(37% 0.077 168.94);
|
/* --- Accent: Vibrant Amber (Light Mode Hue 80) --- */
|
||||||
--color-warning: oklch(82% 0.189 84.43);
|
--color-accent: oklch(75% 0.19 80);
|
||||||
--color-warning-content: oklch(41% 0.112 45.9);
|
--color-accent-content: oklch(18% 0.04 80);
|
||||||
--color-error: oklch(71% 0.194 13.43);
|
|
||||||
--color-error-content: oklch(27% 0.105 12.09);
|
/* --- Neutral: Warm Graphite --- */
|
||||||
|
--color-neutral: oklch(25% 0.02 90);
|
||||||
|
--color-neutral-content: oklch(96% 0.01 90);
|
||||||
|
|
||||||
|
/* --- Semantic Colors (Matching Light Mode Hues) --- */
|
||||||
|
--color-info: oklch(70% 0.15 220); /* Blue */
|
||||||
|
--color-success: oklch(72% 0.18 150); /* Green */
|
||||||
|
--color-warning: oklch(80% 0.18 85); /* Orange/Amber */
|
||||||
|
--color-error: oklch(68% 0.20 25); /* Red */
|
||||||
|
|
||||||
|
--color-info-content: oklch(15% 0.05 220);
|
||||||
|
--color-success-content: oklch(15% 0.05 150);
|
||||||
|
--color-warning-content: oklch(15% 0.05 85);
|
||||||
|
--color-error-content: oklch(98% 0.01 25);
|
||||||
|
|
||||||
|
/* --- Neobrutalist Structure --- */
|
||||||
--radius-selector: 0rem;
|
--radius-selector: 0rem;
|
||||||
--radius-field: 0rem;
|
--radius-field: 0rem;
|
||||||
--radius-box: 0rem;
|
--radius-box: 0rem;
|
||||||
@@ -97,6 +112,103 @@
|
|||||||
--border: 2px;
|
--border: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="obsidian-prism"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
/* --- Canvas & Surfaces --- */
|
||||||
|
--color-base-100: oklch(12% 0.015 260);
|
||||||
|
--color-base-200: oklch(9% 0.018 262);
|
||||||
|
--color-base-300: oklch(6% 0.02 265);
|
||||||
|
--color-base-content: oklch(95% 0.008 260);
|
||||||
|
|
||||||
|
/* --- Primary: Electric Violet Signal --- */
|
||||||
|
--color-primary: oklch(62% 0.28 290);
|
||||||
|
--color-primary-content: oklch(98% 0.01 290);
|
||||||
|
|
||||||
|
/* --- Secondary: Cyan Edge --- */
|
||||||
|
--color-secondary: oklch(68% 0.18 220);
|
||||||
|
--color-secondary-content: oklch(98% 0.01 220);
|
||||||
|
|
||||||
|
/* --- Accent: Ember (warm counterpoint) --- */
|
||||||
|
--color-accent: oklch(78% 0.19 55);
|
||||||
|
--color-accent-content: oklch(18% 0.04 55);
|
||||||
|
|
||||||
|
/* --- Neutral: Cold Steel --- */
|
||||||
|
--color-neutral: oklch(24% 0.02 260);
|
||||||
|
--color-neutral-content: oklch(92% 0.01 260);
|
||||||
|
|
||||||
|
/* --- Semantic Colors --- */
|
||||||
|
--color-info: oklch(72% 0.14 230);
|
||||||
|
--color-info-content: oklch(25% 0.06 230);
|
||||||
|
--color-success: oklch(74% 0.16 155);
|
||||||
|
--color-success-content: oklch(25% 0.06 155);
|
||||||
|
--color-warning: oklch(82% 0.18 75);
|
||||||
|
--color-warning-content: oklch(25% 0.08 75);
|
||||||
|
--color-error: oklch(68% 0.22 15);
|
||||||
|
--color-error-content: oklch(98% 0.02 15);
|
||||||
|
|
||||||
|
/* --- Radii (NB Law: Zero) --- */
|
||||||
|
--radius-selector: 0rem;
|
||||||
|
--radius-field: 0rem;
|
||||||
|
--radius-box: 0rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 2px;
|
||||||
|
|
||||||
|
/* --- Prismatic Shadow System --- */
|
||||||
|
--nb-shadow-hue: 290;
|
||||||
|
--nb-shadow: 4px 4px 0 0 oklch(8% 0.06 var(--nb-shadow-hue));
|
||||||
|
--nb-shadow-hover: 6px 6px 0 0 oklch(6% 0.08 calc(var(--nb-shadow-hue) + 15));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="warm-paper"] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
/* --- Canvas & Surfaces: Warm cream paper (lighter, less yellow) --- */
|
||||||
|
--color-base-100: oklch(98.5% 0.01 90);
|
||||||
|
--color-base-200: oklch(95% 0.015 90);
|
||||||
|
--color-base-300: oklch(92% 0.02 90);
|
||||||
|
--color-base-content: oklch(18% 0.015 75);
|
||||||
|
|
||||||
|
/* --- Primary: Warm Amber/Gold (the landing page CTA color) --- */
|
||||||
|
--color-primary: oklch(72% 0.16 75);
|
||||||
|
--color-primary-content: oklch(18% 0.02 75);
|
||||||
|
|
||||||
|
/* --- Secondary: Warm Terracotta --- */
|
||||||
|
--color-secondary: oklch(55% 0.14 45);
|
||||||
|
--color-secondary-content: oklch(98% 0.01 85);
|
||||||
|
|
||||||
|
/* --- Accent: Deep Charcoal (for contrast buttons like "View on GitHub") --- */
|
||||||
|
--color-accent: oklch(22% 0.01 80);
|
||||||
|
--color-accent-content: oklch(98% 0.02 85);
|
||||||
|
|
||||||
|
/* --- Neutral: Warm Charcoal --- */
|
||||||
|
--color-neutral: oklch(20% 0.015 75);
|
||||||
|
--color-neutral-content: oklch(96% 0.015 85);
|
||||||
|
|
||||||
|
/* --- Semantic Colors (warmer variants) --- */
|
||||||
|
--color-info: oklch(58% 0.12 230);
|
||||||
|
--color-info-content: oklch(98% 0.01 230);
|
||||||
|
--color-success: oklch(62% 0.15 155);
|
||||||
|
--color-success-content: oklch(98% 0.01 155);
|
||||||
|
--color-warning: oklch(78% 0.16 70);
|
||||||
|
--color-warning-content: oklch(20% 0.04 70);
|
||||||
|
--color-error: oklch(58% 0.20 25);
|
||||||
|
--color-error-content: oklch(98% 0.02 25);
|
||||||
|
|
||||||
|
/* --- Radii (NB Law: Zero) --- */
|
||||||
|
--radius-selector: 0rem;
|
||||||
|
--radius-field: 0rem;
|
||||||
|
--radius-box: 0rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 2px;
|
||||||
|
|
||||||
|
/* --- Classic Black Shadow --- */
|
||||||
|
--nb-shadow: 4px 4px 0 0 #000;
|
||||||
|
--nb-shadow-hover: 6px 6px 0 0 #000;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
@@ -608,7 +720,7 @@
|
|||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content :not(pre) > code {
|
.markdown-content :not(pre)>code {
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
padding: 0.15em 0.4em;
|
padding: 0.15em 0.4em;
|
||||||
@@ -662,7 +774,7 @@
|
|||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .markdown-content :not(pre) > code {
|
[data-theme="dark"] .markdown-content :not(pre)>code {
|
||||||
background-color: rgba(255, 255, 255, 0.12);
|
background-color: rgba(255, 255, 255, 0.12);
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
}
|
}
|
||||||
@@ -677,6 +789,136 @@
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
box-shadow: var(--nb-shadow);
|
box-shadow: var(--nb-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* .nb-label: Uppercase, bold, tracking-wide, text-xs for section headers */
|
||||||
|
.nb-label {
|
||||||
|
@apply uppercase font-bold tracking-wide text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .nb-data: JetBrains Mono, tabular-nums for timestamps, IDs, badges */
|
||||||
|
.nb-data {
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Stamp: Button :active state pushes into page */
|
||||||
|
.nb-btn:active {
|
||||||
|
transform: translate(2px, 2px) !important;
|
||||||
|
box-shadow: 2px 2px 0 0 #000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered Card Dealing Animation */
|
||||||
|
@keyframes deal-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered deal-in animation - STRICTLY SCOPED to main content area */
|
||||||
|
main .nb-card,
|
||||||
|
main .nb-panel {
|
||||||
|
animation: deal-in 300ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Exclude elements that shouldn't animate even inside main */
|
||||||
|
main nav.nb-panel,
|
||||||
|
main .no-animation {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply staggered delays only to direct children of grids/lists or top-level containers */
|
||||||
|
main .nb-masonry>.nb-card:nth-child(1),
|
||||||
|
main .grid>.nb-panel:nth-child(1) {
|
||||||
|
animation-delay: 0ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
main .nb-masonry>.nb-card:nth-child(2),
|
||||||
|
main .grid>.nb-panel:nth-child(2) {
|
||||||
|
animation-delay: 50ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
main .nb-masonry>.nb-card:nth-child(3),
|
||||||
|
main .grid>.nb-panel:nth-child(3) {
|
||||||
|
animation-delay: 100ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
main .nb-masonry>.nb-card:nth-child(4),
|
||||||
|
main .grid>.nb-panel:nth-child(4) {
|
||||||
|
animation-delay: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
main .nb-masonry>.nb-card:nth-child(5),
|
||||||
|
main .grid>.nb-panel:nth-child(5) {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
main .nb-masonry>.nb-card:nth-child(6),
|
||||||
|
main .grid>.nb-panel:nth-child(6) {
|
||||||
|
animation-delay: 250ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
main .nb-masonry>.nb-card:nth-child(7),
|
||||||
|
main .grid>.nb-panel:nth-child(7) {
|
||||||
|
animation-delay: 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
main .nb-masonry>.nb-card:nth-child(8),
|
||||||
|
main .grid>.nb-panel:nth-child(8) {
|
||||||
|
animation-delay: 350ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
main .nb-masonry>.nb-card:nth-child(n+9),
|
||||||
|
main .grid>.nb-panel:nth-child(n+9) {
|
||||||
|
animation-delay: 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HTMX Swap Fade-Up Animation */
|
||||||
|
@keyframes fade-up {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-up {
|
||||||
|
animation: fade-up 200ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kinetic Input: Chat Armed State */
|
||||||
|
#chat-input:not(:placeholder-shown)~button {
|
||||||
|
filter: saturate(1.3) brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-input:not(:placeholder-shown) {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Evidence Frame for images (Tufte treatment) */
|
||||||
|
.nb-evidence-frame {
|
||||||
|
@apply border-2 border-neutral m-2 bg-base-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-evidence-frame img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-evidence-frame figcaption {
|
||||||
|
@apply text-xs px-2 py-1 border-t-2 border-neutral;
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme-aware placeholder contrast tweaks */
|
/* Theme-aware placeholder contrast tweaks */
|
||||||
@@ -691,6 +933,31 @@
|
|||||||
color: rgba(255, 255, 255, 0.78) !important;
|
color: rgba(255, 255, 255, 0.78) !important;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === DESIGN POLISHING: Receding Reality === */
|
||||||
|
/* Modal opens → background scales and blurs */
|
||||||
|
body:has(dialog[open]) #main-content-wrapper,
|
||||||
|
body.modal-open #main-content-wrapper {
|
||||||
|
transform: scale(0.98);
|
||||||
|
filter: blur(2px);
|
||||||
|
transition: transform 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)),
|
||||||
|
filter 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-content-wrapper {
|
||||||
|
transform: scale(1);
|
||||||
|
filter: blur(0);
|
||||||
|
transition: transform 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1)),
|
||||||
|
filter 250ms var(--ease-mechanical, cubic-bezier(0.25, 1, 0.5, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === DESIGN POLISHING: Scroll-Linked Navbar Shadow === */
|
||||||
|
nav {
|
||||||
|
--scroll-depth: 0;
|
||||||
|
box-shadow: 4px calc(4px + var(--scroll-depth) * 4px) 0 0 #000;
|
||||||
|
transition: box-shadow 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* satoshi.css */
|
/* satoshi.css */
|
||||||
@@ -714,6 +981,15 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
src: url('fonts/JetBrainsMono-Regular.woff2') format('woff2'),
|
||||||
|
url('fonts/JetBrainsMono-Variable.ttf') format('truetype');
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Minimal override: prevent DaisyUI .menu hover bg on our nb buttons */
|
/* Minimal override: prevent DaisyUI .menu hover bg on our nb buttons */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
@@ -737,3 +1013,111 @@
|
|||||||
@apply text-lg font-bold;
|
@apply text-lg font-bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Prismatic shadow hue shift on hover */
|
||||||
|
[data-theme="obsidian-prism"] .nb-panel:hover,
|
||||||
|
[data-theme="obsidian-prism"] .nb-card:hover,
|
||||||
|
[data-theme="obsidian-prism"] .nb-btn:hover {
|
||||||
|
--nb-shadow-hue: 305;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus state: breathing shadow pulse */
|
||||||
|
@keyframes shadow-breathe {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 6px 6px 0 0 oklch(8% 0.08 305);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 7px 7px 0 0 oklch(10% 0.10 310);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="obsidian-prism"] .nb-btn:focus-visible,
|
||||||
|
[data-theme="obsidian-prism"] .nb-input:focus-visible,
|
||||||
|
[data-theme="obsidian-prism"] .nb-select:focus-visible {
|
||||||
|
animation: shadow-breathe 1.5s ease-in-out infinite;
|
||||||
|
outline: 2px solid oklch(62% 0.28 290);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection color: Prismatic violet */
|
||||||
|
[data-theme="obsidian-prism"] ::selection {
|
||||||
|
background: oklch(62% 0.28 290 / 0.35);
|
||||||
|
color: oklch(98% 0.01 290);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prose adjustments for Obsidian Prism */
|
||||||
|
[data-theme="obsidian-prism"] .prose-tufte,
|
||||||
|
[data-theme="obsidian-prism"] .prose-tufte-compact {
|
||||||
|
color: var(--color-base-content);
|
||||||
|
--tw-prose-body: oklch(92% 0.008 260);
|
||||||
|
--tw-prose-headings: oklch(98% 0.01 260);
|
||||||
|
--tw-prose-lead: oklch(88% 0.01 260);
|
||||||
|
--tw-prose-links: oklch(78% 0.19 55);
|
||||||
|
--tw-prose-bold: oklch(98% 0.01 260);
|
||||||
|
--tw-prose-counters: oklch(70% 0.01 260);
|
||||||
|
--tw-prose-bullets: oklch(50% 0.01 260);
|
||||||
|
--tw-prose-hr: oklch(24% 0.02 260);
|
||||||
|
--tw-prose-quotes: oklch(88% 0.01 260);
|
||||||
|
--tw-prose-quote-borders: oklch(40% 0.04 290);
|
||||||
|
--tw-prose-captions: oklch(70% 0.01 260);
|
||||||
|
--tw-prose-code: oklch(95% 0.008 260);
|
||||||
|
--tw-prose-pre-code: inherit;
|
||||||
|
--tw-prose-pre-bg: oklch(8% 0.02 262);
|
||||||
|
--tw-prose-th-borders: oklch(30% 0.02 260);
|
||||||
|
--tw-prose-td-borders: oklch(24% 0.02 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="obsidian-prism"] .prose-tufte a,
|
||||||
|
[data-theme="obsidian-prism"] .prose-tufte-compact a {
|
||||||
|
color: oklch(78% 0.19 55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks: deeper well */
|
||||||
|
[data-theme="obsidian-prism"] .markdown-content pre {
|
||||||
|
background-color: oklch(7% 0.018 262);
|
||||||
|
border-color: oklch(20% 0.03 290);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="obsidian-prism"] .markdown-content :not(pre)>code {
|
||||||
|
background-color: oklch(18% 0.025 265);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables in Obsidian Prism */
|
||||||
|
[data-theme="obsidian-prism"] .markdown-content th,
|
||||||
|
[data-theme="obsidian-prism"] .markdown-content td {
|
||||||
|
border-color: oklch(24% 0.02 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquotes */
|
||||||
|
[data-theme="obsidian-prism"] .markdown-content blockquote {
|
||||||
|
border-color: oklch(40% 0.04 290);
|
||||||
|
color: oklch(85% 0.01 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HR */
|
||||||
|
[data-theme="obsidian-prism"] .markdown-content hr {
|
||||||
|
border-top-color: oklch(24% 0.02 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox in Obsidian Prism (white tick) */
|
||||||
|
[data-theme="obsidian-prism"] .nb-checkbox:checked {
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><polyline points='20 6 9 17 4 12'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder text */
|
||||||
|
[data-theme="obsidian-prism"] .nb-input::placeholder,
|
||||||
|
[data-theme="obsidian-prism"] .input::placeholder,
|
||||||
|
[data-theme="obsidian-prism"] .textarea::placeholder,
|
||||||
|
[data-theme="obsidian-prism"] textarea::placeholder,
|
||||||
|
[data-theme="obsidian-prism"] input::placeholder {
|
||||||
|
color: oklch(70% 0.01 260) !important;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav shadow uses prismatic color */
|
||||||
|
[data-theme="obsidian-prism"] nav {
|
||||||
|
box-shadow: 4px calc(4px + var(--scroll-depth, 0) * 4px) 0 0 oklch(8% 0.06 290);
|
||||||
|
}
|
||||||
199
html-router/assets/design-polish.js
Normal file
199
html-router/assets/design-polish.js
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Design Polishing Pass - Interactive Effects
|
||||||
|
*
|
||||||
|
* Includes:
|
||||||
|
* - Scroll-Linked Navbar Shadow
|
||||||
|
* - HTMX Swap Animation
|
||||||
|
* - Typewriter AI Response
|
||||||
|
* - Rubberbanding Scroll
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// === SCROLL-LINKED NAVBAR SHADOW ===
|
||||||
|
function initScrollShadow() {
|
||||||
|
const mainContent = document.querySelector('main');
|
||||||
|
const navbar = document.querySelector('nav');
|
||||||
|
if (!mainContent || !navbar) return;
|
||||||
|
|
||||||
|
mainContent.addEventListener('scroll', () => {
|
||||||
|
const scrollTop = mainContent.scrollTop;
|
||||||
|
const scrollHeight = mainContent.scrollHeight - mainContent.clientHeight;
|
||||||
|
const scrollDepth = scrollHeight > 0 ? Math.min(scrollTop / 200, 1) : 0;
|
||||||
|
navbar.style.setProperty('--scroll-depth', scrollDepth.toFixed(2));
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// === HTMX SWAP ANIMATION ===
|
||||||
|
function initHtmxSwapAnimation() {
|
||||||
|
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||||
|
let target = event.detail.target;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
// If full body swap (hx-boost), animate only the main content
|
||||||
|
if (target.tagName === 'BODY') {
|
||||||
|
const main = document.querySelector('main');
|
||||||
|
if (main) target = main;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only animate if target is valid and inside/is main content or a card/panel
|
||||||
|
// Avoid animating sidebar or navbar updates
|
||||||
|
if (target && (target.tagName === 'MAIN' || target.closest('main'))) {
|
||||||
|
if (!target.classList.contains('animate-fade-up')) {
|
||||||
|
target.classList.add('animate-fade-up');
|
||||||
|
// Remove class after animation completes to allow re-animation
|
||||||
|
setTimeout(() => {
|
||||||
|
target.classList.remove('animate-fade-up');
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === TYPEWRITER AI RESPONSE ===
|
||||||
|
// Works with SSE streaming - buffers text and reveals character by character
|
||||||
|
window.initTypewriter = function(element, options = {}) {
|
||||||
|
const {
|
||||||
|
minDelay = 5,
|
||||||
|
maxDelay = 15,
|
||||||
|
showCursor = true
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
let isTyping = false;
|
||||||
|
let cursorElement = null;
|
||||||
|
|
||||||
|
if (showCursor) {
|
||||||
|
cursorElement = document.createElement('span');
|
||||||
|
cursorElement.className = 'typewriter-cursor';
|
||||||
|
cursorElement.textContent = '▌';
|
||||||
|
cursorElement.style.animation = 'blink 1s step-end infinite';
|
||||||
|
element.appendChild(cursorElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeNextChar() {
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
isTyping = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isTyping = true;
|
||||||
|
const char = buffer.charAt(0);
|
||||||
|
buffer = buffer.slice(1);
|
||||||
|
|
||||||
|
// Insert before cursor
|
||||||
|
if (cursorElement && cursorElement.parentNode) {
|
||||||
|
const textNode = document.createTextNode(char);
|
||||||
|
element.insertBefore(textNode, cursorElement);
|
||||||
|
} else {
|
||||||
|
element.textContent += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = minDelay + Math.random() * (maxDelay - minDelay);
|
||||||
|
setTimeout(typeNextChar, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
append: function(text) {
|
||||||
|
buffer += text;
|
||||||
|
if (!isTyping) {
|
||||||
|
typeNextChar();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
// Flush remaining buffer immediately
|
||||||
|
if (cursorElement && cursorElement.parentNode) {
|
||||||
|
const textNode = document.createTextNode(buffer);
|
||||||
|
element.insertBefore(textNode, cursorElement);
|
||||||
|
cursorElement.remove();
|
||||||
|
} else {
|
||||||
|
element.textContent += buffer;
|
||||||
|
}
|
||||||
|
buffer = '';
|
||||||
|
isTyping = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// === RUBBERBANDING SCROLL ===
|
||||||
|
function initRubberbanding() {
|
||||||
|
const containers = document.querySelectorAll('#chat-scroll-container, .content-scroll-container');
|
||||||
|
|
||||||
|
containers.forEach(container => {
|
||||||
|
let startY = 0;
|
||||||
|
let pulling = false;
|
||||||
|
let pullDistance = 0;
|
||||||
|
const maxPull = 60;
|
||||||
|
const resistance = 0.4;
|
||||||
|
|
||||||
|
container.addEventListener('touchstart', (e) => {
|
||||||
|
startY = e.touches[0].clientY;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
container.addEventListener('touchmove', (e) => {
|
||||||
|
const currentY = e.touches[0].clientY;
|
||||||
|
const diff = currentY - startY;
|
||||||
|
|
||||||
|
// At top boundary, pulling down
|
||||||
|
if (container.scrollTop <= 0 && diff > 0) {
|
||||||
|
pulling = true;
|
||||||
|
pullDistance = Math.min(diff * resistance, maxPull);
|
||||||
|
container.style.transform = `translateY(${pullDistance}px)`;
|
||||||
|
}
|
||||||
|
// At bottom boundary, pulling up
|
||||||
|
else if (container.scrollTop + container.clientHeight >= container.scrollHeight && diff < 0) {
|
||||||
|
pulling = true;
|
||||||
|
pullDistance = Math.max(diff * resistance, -maxPull);
|
||||||
|
container.style.transform = `translateY(${pullDistance}px)`;
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
container.addEventListener('touchend', () => {
|
||||||
|
if (pulling) {
|
||||||
|
container.style.transition = 'transform 300ms cubic-bezier(0.25, 1, 0.5, 1)';
|
||||||
|
container.style.transform = 'translateY(0)';
|
||||||
|
setTimeout(() => {
|
||||||
|
container.style.transition = '';
|
||||||
|
}, 300);
|
||||||
|
pulling = false;
|
||||||
|
pullDistance = 0;
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === INITIALIZATION ===
|
||||||
|
function init() {
|
||||||
|
initScrollShadow();
|
||||||
|
initHtmxSwapAnimation();
|
||||||
|
initRubberbanding();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on DOMContentLoaded
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-init rubberbanding after HTMX navigations
|
||||||
|
document.body.addEventListener('htmx:afterSettle', () => {
|
||||||
|
initRubberbanding();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add typewriter cursor blink animation
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
.typewriter-cursor {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
})();
|
||||||
BIN
html-router/assets/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
html-router/assets/fonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
BIN
html-router/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
BIN
html-router/assets/fonts/JetBrainsMono-Variable.ttf
Normal file
Binary file not shown.
@@ -1,144 +0,0 @@
|
|||||||
//==========================================================
|
|
||||||
// head-support.js
|
|
||||||
//
|
|
||||||
// An extension to add head tag merging.
|
|
||||||
//==========================================================
|
|
||||||
(function(){
|
|
||||||
|
|
||||||
var api = null;
|
|
||||||
|
|
||||||
function log() {
|
|
||||||
//console.log(arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeHead(newContent, defaultMergeStrategy) {
|
|
||||||
|
|
||||||
if (newContent && newContent.indexOf('<head') > -1) {
|
|
||||||
const htmlDoc = document.createElement("html");
|
|
||||||
// remove svgs to avoid conflicts
|
|
||||||
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
|
|
||||||
// extract head tag
|
|
||||||
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
|
|
||||||
|
|
||||||
// if the head tag exists...
|
|
||||||
if (headTag) {
|
|
||||||
|
|
||||||
var added = []
|
|
||||||
var removed = []
|
|
||||||
var preserved = []
|
|
||||||
var nodesToAppend = []
|
|
||||||
|
|
||||||
htmlDoc.innerHTML = headTag;
|
|
||||||
var newHeadTag = htmlDoc.querySelector("head");
|
|
||||||
var currentHead = document.head;
|
|
||||||
|
|
||||||
if (newHeadTag == null) {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
// put all new head elements into a Map, by their outerHTML
|
|
||||||
var srcToNewHeadNodes = new Map();
|
|
||||||
for (const newHeadChild of newHeadTag.children) {
|
|
||||||
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// determine merge strategy
|
|
||||||
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
|
|
||||||
|
|
||||||
// get the current head
|
|
||||||
for (const currentHeadElt of currentHead.children) {
|
|
||||||
|
|
||||||
// If the current head element is in the map
|
|
||||||
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
|
|
||||||
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
|
|
||||||
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
|
|
||||||
if (inNewContent || isPreserved) {
|
|
||||||
if (isReAppended) {
|
|
||||||
// remove the current version and let the new version replace it and re-execute
|
|
||||||
removed.push(currentHeadElt);
|
|
||||||
} else {
|
|
||||||
// this element already exists and should not be re-appended, so remove it from
|
|
||||||
// the new content map, preserving it in the DOM
|
|
||||||
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
|
|
||||||
preserved.push(currentHeadElt);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (mergeStrategy === "append") {
|
|
||||||
// we are appending and this existing element is not new content
|
|
||||||
// so if and only if it is marked for re-append do we do anything
|
|
||||||
if (isReAppended) {
|
|
||||||
removed.push(currentHeadElt);
|
|
||||||
nodesToAppend.push(currentHeadElt);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if this is a merge, we remove this content since it is not in the new head
|
|
||||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
|
|
||||||
removed.push(currentHeadElt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push the tremaining new head elements in the Map into the
|
|
||||||
// nodes to append to the head tag
|
|
||||||
nodesToAppend.push(...srcToNewHeadNodes.values());
|
|
||||||
log("to append: ", nodesToAppend);
|
|
||||||
|
|
||||||
for (const newNode of nodesToAppend) {
|
|
||||||
log("adding: ", newNode);
|
|
||||||
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
|
|
||||||
log(newElt);
|
|
||||||
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
|
|
||||||
currentHead.appendChild(newElt);
|
|
||||||
added.push(newElt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove all removed elements, after we have appended the new elements to avoid
|
|
||||||
// additional network requests for things like style sheets
|
|
||||||
for (const removedElement of removed) {
|
|
||||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
|
|
||||||
currentHead.removeChild(removedElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
htmx.defineExtension("head-support", {
|
|
||||||
init: function(apiRef) {
|
|
||||||
// store a reference to the internal API.
|
|
||||||
api = apiRef;
|
|
||||||
|
|
||||||
htmx.on('htmx:afterSwap', function(evt){
|
|
||||||
let xhr = evt.detail.xhr;
|
|
||||||
if (xhr) {
|
|
||||||
var serverResponse = xhr.response;
|
|
||||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
|
||||||
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
htmx.on('htmx:historyRestore', function(evt){
|
|
||||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
|
||||||
if (evt.detail.cacheMiss) {
|
|
||||||
mergeHead(evt.detail.serverResponse, "merge");
|
|
||||||
} else {
|
|
||||||
mergeHead(evt.detail.item.head, "merge");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
htmx.on('htmx:historyItemCreated', function(evt){
|
|
||||||
var historyItem = evt.detail.item;
|
|
||||||
historyItem.head = document.head.outerHTML;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
})()
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,32 +1,79 @@
|
|||||||
|
// Global media query and listener state
|
||||||
|
const systemMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
let isSystemListenerAttached = false;
|
||||||
|
|
||||||
|
const handleSystemThemeChange = (e) => {
|
||||||
|
const themePreference = document.documentElement.getAttribute('data-theme-preference');
|
||||||
|
if (themePreference === 'system') {
|
||||||
|
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
// For explicit themes like 'obsidian-prism', 'light', 'dark' - do nothing on system change
|
||||||
|
};
|
||||||
|
|
||||||
const initializeTheme = () => {
|
const initializeTheme = () => {
|
||||||
const themeToggle = document.querySelector('.theme-controller');
|
const themeToggle = document.querySelector('.theme-controller');
|
||||||
if (!themeToggle) {
|
const themePreference = document.documentElement.getAttribute('data-theme-preference');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect system preference
|
if (themeToggle) {
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
// Anonymous mode
|
||||||
|
if (isSystemListenerAttached) {
|
||||||
|
systemMediaQuery.removeEventListener('change', handleSystemThemeChange);
|
||||||
|
isSystemListenerAttached = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize theme from local storage or system preference
|
// Avoid re-binding if already bound
|
||||||
const savedTheme = localStorage.getItem('theme');
|
if (themeToggle.dataset.bound) return;
|
||||||
const initialTheme = savedTheme ? savedTheme : (prefersDark ? 'dark' : 'light');
|
themeToggle.dataset.bound = "true";
|
||||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
|
||||||
themeToggle.checked = initialTheme === 'dark';
|
|
||||||
|
|
||||||
// Update theme and local storage on toggle
|
// Detect system preference
|
||||||
themeToggle.addEventListener('change', () => {
|
const prefersDark = systemMediaQuery.matches;
|
||||||
const theme = themeToggle.checked ? 'dark' : 'light';
|
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
});
|
|
||||||
|
|
||||||
};
|
// Initialize theme from local storage or system preference
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
const initialTheme = savedTheme ? savedTheme : (prefersDark ? 'dark' : 'light');
|
||||||
|
document.documentElement.setAttribute('data-theme', initialTheme);
|
||||||
|
themeToggle.checked = initialTheme === 'dark';
|
||||||
|
|
||||||
// Run the initialization after the DOM is fully loaded
|
// Update theme and local storage on toggle
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
themeToggle.addEventListener('change', () => {
|
||||||
initializeTheme();
|
const theme = themeToggle.checked ? 'dark' : 'light';
|
||||||
});
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
});
|
||||||
|
|
||||||
// Reinitialize theme toggle after HTMX swaps
|
} else {
|
||||||
document.addEventListener('htmx:afterSwap', initializeTheme);
|
// Authenticated mode
|
||||||
document.addEventListener('htmx:afterSettle', initializeTheme);
|
localStorage.removeItem('theme');
|
||||||
|
|
||||||
|
if (themePreference === 'system') {
|
||||||
|
// Ensure correct theme is set immediately
|
||||||
|
const currentSystemTheme = systemMediaQuery.matches ? 'dark' : 'light';
|
||||||
|
// Only update if needed
|
||||||
|
if (document.documentElement.getAttribute('data-theme') !== currentSystemTheme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', currentSystemTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSystemListenerAttached) {
|
||||||
|
systemMediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||||
|
isSystemListenerAttached = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Explicit theme: 'light', 'dark', 'obsidian-prism', etc.
|
||||||
|
if (isSystemListenerAttached) {
|
||||||
|
systemMediaQuery.removeEventListener('change', handleSystemThemeChange);
|
||||||
|
isSystemListenerAttached = false;
|
||||||
|
}
|
||||||
|
// Ensure data-theme matches preference exactly
|
||||||
|
if (themePreference && document.documentElement.getAttribute('data-theme') !== themePreference) {
|
||||||
|
document.documentElement.setAttribute('data-theme', themePreference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run the initialization after the DOM is fully loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeTheme);
|
||||||
|
|
||||||
|
// Reinitialize theme toggle after HTMX swaps
|
||||||
|
document.addEventListener('htmx:afterSwap', initializeTheme);
|
||||||
|
document.addEventListener('htmx:afterSettle', initializeTheme);
|
||||||
|
|||||||
@@ -29,15 +29,17 @@ impl HtmlState {
|
|||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
reranker_pool: Option<Arc<RerankerPool>>,
|
reranker_pool: Option<Arc<RerankerPool>>,
|
||||||
embedding_provider: Arc<EmbeddingProvider>,
|
embedding_provider: Arc<EmbeddingProvider>,
|
||||||
|
template_engine: Option<Arc<TemplateEngine>>,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let template_engine = create_template_engine!("templates");
|
let templates =
|
||||||
debug!("Template engine created for html_router.");
|
template_engine.unwrap_or_else(|| Arc::new(create_template_engine!("templates")));
|
||||||
|
debug!("Template engine configured for html_router.");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
db,
|
db,
|
||||||
openai_client,
|
openai_client,
|
||||||
session_store,
|
session_store,
|
||||||
templates: Arc::new(template_engine),
|
templates,
|
||||||
config,
|
config,
|
||||||
storage,
|
storage,
|
||||||
reranker_pool,
|
reranker_pool,
|
||||||
@@ -63,3 +65,9 @@ impl ProvidesTemplateEngine for HtmlState {
|
|||||||
&self.templates
|
&self.templates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl crate::middlewares::response_middleware::ProvidesHtmlState for HtmlState {
|
||||||
|
fn html_state(&self) -> &HtmlState {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Request, State},
|
||||||
http::{HeaderName, StatusCode},
|
http::{HeaderName, StatusCode},
|
||||||
|
middleware::Next,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
Extension,
|
Extension,
|
||||||
};
|
};
|
||||||
use axum_htmx::{HxRequest, HX_TRIGGER};
|
use axum_htmx::{HxRequest, HX_TRIGGER};
|
||||||
use common::{error::AppError, utils::template_engine::ProvidesTemplateEngine};
|
use common::{
|
||||||
use minijinja::{context, Value};
|
error::AppError,
|
||||||
|
utils::template_engine::{ProvidesTemplateEngine, Value},
|
||||||
|
};
|
||||||
|
use minijinja::context;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::{html_state::HtmlState, AuthSessionType};
|
||||||
|
use common::storage::types::{
|
||||||
|
conversation::Conversation,
|
||||||
|
user::{Theme, User},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait ProvidesHtmlState {
|
||||||
|
fn html_state(&self) -> &HtmlState;
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum TemplateKind {
|
pub enum TemplateKind {
|
||||||
Full(String),
|
Full(String),
|
||||||
@@ -98,20 +114,64 @@ impl IntoResponse for TemplateResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ContextWrapper<'a> {
|
||||||
|
user_theme: &'a str,
|
||||||
|
initial_theme: &'a str,
|
||||||
|
is_authenticated: bool,
|
||||||
|
user: Option<&'a User>,
|
||||||
|
conversation_archive: Vec<Conversation>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
context: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn with_template_response<S>(
|
pub async fn with_template_response<S>(
|
||||||
State(state): State<S>,
|
State(state): State<S>,
|
||||||
HxRequest(is_htmx): HxRequest,
|
HxRequest(is_htmx): HxRequest,
|
||||||
response: Response<axum::body::Body>,
|
req: Request,
|
||||||
) -> Response<axum::body::Body>
|
next: Next,
|
||||||
|
) -> Response
|
||||||
where
|
where
|
||||||
S: ProvidesTemplateEngine + Clone + Send + Sync + 'static,
|
S: ProvidesTemplateEngine + ProvidesHtmlState + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
|
let mut user_theme = Theme::System.as_str();
|
||||||
|
let mut initial_theme = Theme::System.initial_theme();
|
||||||
|
let mut is_authenticated = false;
|
||||||
|
let mut current_user_id = None;
|
||||||
|
|
||||||
|
{
|
||||||
|
if let Some(auth) = req.extensions().get::<AuthSessionType>() {
|
||||||
|
if let Some(user) = &auth.current_user {
|
||||||
|
is_authenticated = true;
|
||||||
|
current_user_id = Some(user.id.clone());
|
||||||
|
user_theme = user.theme.as_str();
|
||||||
|
initial_theme = user.theme.initial_theme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = next.run(req).await;
|
||||||
|
|
||||||
// Headers to forward from the original response
|
// Headers to forward from the original response
|
||||||
const HTMX_HEADERS_TO_FORWARD: &[&str] = &["HX-Push", "HX-Trigger", "HX-Redirect"];
|
const HTMX_HEADERS_TO_FORWARD: &[&str] = &["HX-Push", "HX-Trigger", "HX-Redirect"];
|
||||||
|
|
||||||
if let Some(template_response) = response.extensions().get::<TemplateResponse>().cloned() {
|
if let Some(template_response) = response.extensions().get::<TemplateResponse>().cloned() {
|
||||||
let template_engine = state.template_engine();
|
let template_engine = state.template_engine();
|
||||||
|
|
||||||
|
let mut current_user = None;
|
||||||
|
let mut conversation_archive = Vec::new();
|
||||||
|
|
||||||
|
if let Some(user_id) = current_user_id {
|
||||||
|
let html_state = state.html_state();
|
||||||
|
if let Ok(Some(user)) = html_state.db.get_item::<User>(&user_id).await {
|
||||||
|
// Fetch conversation archive globally for authenticated users
|
||||||
|
if let Ok(archive) = User::get_user_conversations(&user.id, &html_state.db).await {
|
||||||
|
conversation_archive = archive;
|
||||||
|
}
|
||||||
|
current_user = Some(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to forward relevant headers
|
// Helper to forward relevant headers
|
||||||
fn forward_headers(from: &axum::http::HeaderMap, to: &mut axum::http::HeaderMap) {
|
fn forward_headers(from: &axum::http::HeaderMap, to: &mut axum::http::HeaderMap) {
|
||||||
for &header_name in HTMX_HEADERS_TO_FORWARD {
|
for &header_name in HTMX_HEADERS_TO_FORWARD {
|
||||||
@@ -123,9 +183,33 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert minijinja::Value to HashMap if it's a map, otherwise use empty HashMap
|
||||||
|
let context_map = if template_response.context.kind() == minijinja::value::ValueKind::Map {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
if let Ok(keys) = template_response.context.try_iter() {
|
||||||
|
for key in keys {
|
||||||
|
if let Ok(val) = template_response.context.get_item(&key) {
|
||||||
|
map.insert(key.to_string(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
} else {
|
||||||
|
HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let context = ContextWrapper {
|
||||||
|
user_theme: &user_theme,
|
||||||
|
initial_theme: &initial_theme,
|
||||||
|
is_authenticated,
|
||||||
|
user: current_user.as_ref(),
|
||||||
|
conversation_archive,
|
||||||
|
context: context_map,
|
||||||
|
};
|
||||||
|
|
||||||
match &template_response.template_kind {
|
match &template_response.template_kind {
|
||||||
TemplateKind::Full(name) => {
|
TemplateKind::Full(name) => {
|
||||||
match template_engine.render(name, &template_response.context) {
|
match template_engine.render(name, &Value::from_serialize(&context)) {
|
||||||
Ok(html) => {
|
Ok(html) => {
|
||||||
let mut final_response = Html(html).into_response();
|
let mut final_response = Html(html).into_response();
|
||||||
forward_headers(response.headers(), final_response.headers_mut());
|
forward_headers(response.headers(), final_response.headers_mut());
|
||||||
@@ -138,7 +222,11 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
TemplateKind::Partial(template, block) => {
|
TemplateKind::Partial(template, block) => {
|
||||||
match template_engine.render_block(template, block, &template_response.context) {
|
match template_engine.render_block(
|
||||||
|
template,
|
||||||
|
block,
|
||||||
|
&Value::from_serialize(&context),
|
||||||
|
) {
|
||||||
Ok(html) => {
|
Ok(html) => {
|
||||||
let mut final_response = Html(html).into_response();
|
let mut final_response = Html(html).into_response();
|
||||||
forward_headers(response.headers(), final_response.headers_mut());
|
forward_headers(response.headers(), final_response.headers_mut());
|
||||||
@@ -169,12 +257,15 @@ where
|
|||||||
let trigger_payload = json!({"toast": {"title": title, "description": description, "type": "error"}});
|
let trigger_payload = json!({"toast": {"title": title, "description": description, "type": "error"}});
|
||||||
let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|e| {
|
let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|e| {
|
||||||
error!("Failed to serialize HX-Trigger payload: {}", e);
|
error!("Failed to serialize HX-Trigger payload: {}", e);
|
||||||
r#"{"toast":{"title":"Error","description":"An unexpected error occurred.", "type":"error"}}"#.to_string()
|
r#"{"toast":{"title":"Error","description":"An unexpected error occurred.", "type":"error"}}"#
|
||||||
|
.to_string()
|
||||||
});
|
});
|
||||||
(StatusCode::NO_CONTENT, [(HX_TRIGGER, trigger_value)], "").into_response()
|
(StatusCode::NO_CONTENT, [(HX_TRIGGER, trigger_value)], "").into_response()
|
||||||
} else {
|
} else {
|
||||||
// Non-HTMX request: Render the full errors/error.html page
|
// Non-HTMX request: Render the full errors/error.html page
|
||||||
match template_engine.render("errors/error.html", &template_response.context) {
|
match template_engine
|
||||||
|
.render("errors/error.html", &Value::from_serialize(&context))
|
||||||
|
{
|
||||||
Ok(html) => (*status, Html(html)).into_response(),
|
Ok(html) => (*status, Html(html)).into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Critical: Failed to render 'errors/error.html': {:?}", e);
|
error!("Critical: Failed to render 'errors/error.html': {:?}", e);
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
use axum::{
|
use axum::{extract::FromRef, middleware::from_fn_with_state, Router};
|
||||||
extract::FromRef,
|
|
||||||
middleware::{from_fn_with_state, map_response_with_state},
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
use axum_session::SessionLayer;
|
use axum_session::SessionLayer;
|
||||||
use axum_session_auth::{AuthConfig, AuthSessionLayer};
|
use axum_session_auth::{AuthConfig, AuthSessionLayer};
|
||||||
use axum_session_surreal::SessionSurrealPool;
|
use axum_session_surreal::SessionSurrealPool;
|
||||||
@@ -124,79 +120,80 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(self) -> Router<S> {
|
pub fn build(self) -> Router<S> {
|
||||||
// Start with an empty router
|
// Build the "App" router (Pages, API interactions, etc.)
|
||||||
let mut public_router = Router::new();
|
let mut app_router = Router::new();
|
||||||
|
|
||||||
// Merge all public routers
|
// Merge all public routers (pages)
|
||||||
for router in self.public_routers {
|
for router in self.public_routers {
|
||||||
public_router = public_router.merge(router);
|
app_router = app_router.merge(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add nested public routes
|
// Add nested public routes
|
||||||
for (path, router) in self.nested_routes {
|
for (path, router) in self.nested_routes {
|
||||||
public_router = public_router.nest(&path, router);
|
app_router = app_router.nest(&path, router);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add public assets to public router
|
// Build protected router logic...
|
||||||
if let Some(assets_config) = self.public_assets_config {
|
|
||||||
// Call the macro using the stored relative directory path
|
|
||||||
let asset_service = create_asset_service!(&assets_config.directory);
|
|
||||||
// Nest the resulting service under the stored URL path
|
|
||||||
public_router = public_router.nest_service(&assets_config.path, asset_service);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start with an empty protected router
|
|
||||||
let mut protected_router = Router::new();
|
let mut protected_router = Router::new();
|
||||||
|
|
||||||
// Check if there are any protected routers
|
|
||||||
let has_protected_routes =
|
let has_protected_routes =
|
||||||
!self.protected_routers.is_empty() || !self.nested_protected_routes.is_empty();
|
!self.protected_routers.is_empty() || !self.nested_protected_routes.is_empty();
|
||||||
|
|
||||||
// Merge root-level protected routers
|
|
||||||
for router in self.protected_routers {
|
for router in self.protected_routers {
|
||||||
protected_router = protected_router.merge(router);
|
protected_router = protected_router.merge(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nest protected routers
|
|
||||||
for (path, router) in self.nested_protected_routes {
|
for (path, router) in self.nested_protected_routes {
|
||||||
protected_router = protected_router.nest(&path, router);
|
protected_router = protected_router.nest(&path, router);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply auth middleware
|
|
||||||
if has_protected_routes {
|
if has_protected_routes {
|
||||||
protected_router = protected_router
|
protected_router = protected_router
|
||||||
.route_layer(from_fn_with_state(self.app_state.clone(), require_auth));
|
.route_layer(from_fn_with_state(self.app_state.clone(), require_auth));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine public and protected routes
|
// Combine public and protected routes into the App router
|
||||||
let mut router = Router::new().merge(public_router).merge(protected_router);
|
app_router = app_router.merge(protected_router);
|
||||||
|
|
||||||
// Apply custom middleware in order they were added
|
// Apply custom middleware to the App router
|
||||||
for middleware_fn in self.custom_middleware {
|
for middleware_fn in self.custom_middleware {
|
||||||
router = middleware_fn(router);
|
app_router = middleware_fn(app_router);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply common middleware
|
// Apply App-specific Middleware (Analytics, Template, Auth, Session)
|
||||||
router = router.layer(from_fn_with_state(
|
app_router = app_router.layer(from_fn_with_state(
|
||||||
self.app_state.clone(),
|
self.app_state.clone(),
|
||||||
analytics_middleware::<HtmlState>,
|
analytics_middleware::<HtmlState>,
|
||||||
));
|
));
|
||||||
router = router.layer(map_response_with_state(
|
app_router = app_router.layer(from_fn_with_state(
|
||||||
self.app_state.clone(),
|
self.app_state.clone(),
|
||||||
with_template_response::<HtmlState>,
|
with_template_response::<HtmlState>,
|
||||||
));
|
));
|
||||||
router = router.layer(
|
app_router = app_router.layer(
|
||||||
AuthSessionLayer::<User, String, SessionSurrealPool<Any>, Surreal<Any>>::new(Some(
|
AuthSessionLayer::<User, String, SessionSurrealPool<Any>, Surreal<Any>>::new(Some(
|
||||||
self.app_state.db.client.clone(),
|
self.app_state.db.client.clone(),
|
||||||
))
|
))
|
||||||
.with_config(AuthConfig::<String>::default()),
|
.with_config(AuthConfig::<String>::default()),
|
||||||
);
|
);
|
||||||
router = router.layer(SessionLayer::new((*self.app_state.session_store).clone()));
|
app_router = app_router.layer(SessionLayer::new((*self.app_state.session_store).clone()));
|
||||||
|
|
||||||
if self.compression_enabled {
|
// Build the Final router, starting with assets (bypassing app middleware)
|
||||||
router = router.layer(compression_layer());
|
let mut final_router = Router::new();
|
||||||
|
|
||||||
|
if let Some(assets_config) = self.public_assets_config {
|
||||||
|
// Call the macro using the stored relative directory path
|
||||||
|
let asset_service = create_asset_service!(&assets_config.directory);
|
||||||
|
// Nest the resulting service under the stored URL path
|
||||||
|
final_router = final_router.nest_service(&assets_config.path, asset_service);
|
||||||
}
|
}
|
||||||
|
|
||||||
router
|
// Merge the App router
|
||||||
|
final_router = final_router.merge(app_router);
|
||||||
|
|
||||||
|
// Apply Global Middleware (Compression)
|
||||||
|
if self.compression_enabled {
|
||||||
|
final_router = final_router.layer(compression_layer());
|
||||||
|
}
|
||||||
|
|
||||||
|
final_router
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,33 +9,37 @@ use crate::{
|
|||||||
},
|
},
|
||||||
AuthSessionType,
|
AuthSessionType,
|
||||||
};
|
};
|
||||||
use common::storage::types::{conversation::Conversation, user::User};
|
use common::storage::types::user::{Theme, User};
|
||||||
|
|
||||||
use crate::html_state::HtmlState;
|
use crate::html_state::HtmlState;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct AccountPageData {
|
pub struct AccountPageData {
|
||||||
user: User,
|
|
||||||
timezones: Vec<String>,
|
timezones: Vec<String>,
|
||||||
conversation_archive: Vec<Conversation>,
|
theme_options: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn show_account_page(
|
pub async fn show_account_page(
|
||||||
RequireUser(user): RequireUser,
|
RequireUser(_user): RequireUser,
|
||||||
State(state): State<HtmlState>,
|
State(_state): State<HtmlState>,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
let timezones = TZ_VARIANTS
|
let timezones = TZ_VARIANTS
|
||||||
.iter()
|
.iter()
|
||||||
.map(std::string::ToString::to_string)
|
.map(std::string::ToString::to_string)
|
||||||
.collect();
|
.collect();
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
let theme_options = vec![
|
||||||
|
Theme::Light.as_str().to_string(),
|
||||||
|
Theme::Dark.as_str().to_string(),
|
||||||
|
Theme::WarmPaper.as_str().to_string(),
|
||||||
|
Theme::ObsidianPrism.as_str().to_string(),
|
||||||
|
Theme::System.as_str().to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"auth/account_settings.html",
|
"auth/account_settings.html",
|
||||||
AccountPageData {
|
AccountPageData {
|
||||||
user,
|
|
||||||
timezones,
|
timezones,
|
||||||
conversation_archive,
|
theme_options,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -46,25 +50,18 @@ pub async fn set_api_key(
|
|||||||
auth: AuthSessionType,
|
auth: AuthSessionType,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
// Generate and set the API key
|
// Generate and set the API key
|
||||||
let api_key = User::set_api_key(&user.id, &state.db).await?;
|
User::set_api_key(&user.id, &state.db).await?;
|
||||||
|
|
||||||
// Clear the cache so new requests have access to the user with api key
|
// Clear the cache so new requests have access to the user with api key
|
||||||
auth.cache_clear_user(user.id.to_string());
|
auth.cache_clear_user(user.id.to_string());
|
||||||
|
|
||||||
// Update the user's API key
|
|
||||||
let updated_user = User {
|
|
||||||
api_key: Some(api_key),
|
|
||||||
..user.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render the API key section block
|
// Render the API key section block
|
||||||
Ok(TemplateResponse::new_partial(
|
Ok(TemplateResponse::new_partial(
|
||||||
"auth/account_settings.html",
|
"auth/account_settings.html",
|
||||||
"api_key_section",
|
"api_key_section",
|
||||||
AccountPageData {
|
AccountPageData {
|
||||||
user: updated_user,
|
|
||||||
timezones: vec![],
|
timezones: vec![],
|
||||||
conversation_archive: vec![],
|
theme_options: vec![],
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -99,12 +96,6 @@ pub async fn update_timezone(
|
|||||||
// Clear the cache
|
// Clear the cache
|
||||||
auth.cache_clear_user(user.id.to_string());
|
auth.cache_clear_user(user.id.to_string());
|
||||||
|
|
||||||
// Update the user's API key
|
|
||||||
let updated_user = User {
|
|
||||||
timezone: form.timezone,
|
|
||||||
..user.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let timezones = TZ_VARIANTS
|
let timezones = TZ_VARIANTS
|
||||||
.iter()
|
.iter()
|
||||||
.map(std::string::ToString::to_string)
|
.map(std::string::ToString::to_string)
|
||||||
@@ -115,9 +106,42 @@ pub async fn update_timezone(
|
|||||||
"auth/account_settings.html",
|
"auth/account_settings.html",
|
||||||
"timezone_section",
|
"timezone_section",
|
||||||
AccountPageData {
|
AccountPageData {
|
||||||
user: updated_user,
|
|
||||||
timezones,
|
timezones,
|
||||||
conversation_archive: vec![],
|
theme_options: vec![],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateThemeForm {
|
||||||
|
theme: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_theme(
|
||||||
|
State(state): State<HtmlState>,
|
||||||
|
RequireUser(user): RequireUser,
|
||||||
|
auth: AuthSessionType,
|
||||||
|
Form(form): Form<UpdateThemeForm>,
|
||||||
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
|
User::update_theme(&user.id, &form.theme, &state.db).await?;
|
||||||
|
|
||||||
|
// Clear the cache
|
||||||
|
auth.cache_clear_user(user.id.to_string());
|
||||||
|
|
||||||
|
let theme_options = vec![
|
||||||
|
Theme::Light.as_str().to_string(),
|
||||||
|
Theme::Dark.as_str().to_string(),
|
||||||
|
Theme::WarmPaper.as_str().to_string(),
|
||||||
|
Theme::ObsidianPrism.as_str().to_string(),
|
||||||
|
Theme::System.as_str().to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
Ok(TemplateResponse::new_partial(
|
||||||
|
"auth/account_settings.html",
|
||||||
|
"theme_section",
|
||||||
|
AccountPageData {
|
||||||
|
timezones: vec![],
|
||||||
|
theme_options,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ where
|
|||||||
.route("/account", get(handlers::show_account_page))
|
.route("/account", get(handlers::show_account_page))
|
||||||
.route("/set-api-key", post(handlers::set_api_key))
|
.route("/set-api-key", post(handlers::set_api_key))
|
||||||
.route("/update-timezone", patch(handlers::update_timezone))
|
.route("/update-timezone", patch(handlers::update_timezone))
|
||||||
|
.route("/update-theme", patch(handlers::update_theme))
|
||||||
.route(
|
.route(
|
||||||
"/change-password",
|
"/change-password",
|
||||||
get(handlers::show_change_password).patch(handlers::change_password),
|
get(handlers::show_change_password).patch(handlers::change_password),
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use common::{
|
|||||||
error::AppError,
|
error::AppError,
|
||||||
storage::types::{
|
storage::types::{
|
||||||
analytics::Analytics,
|
analytics::Analytics,
|
||||||
conversation::Conversation,
|
|
||||||
knowledge_entity::KnowledgeEntity,
|
knowledge_entity::KnowledgeEntity,
|
||||||
system_prompts::{
|
system_prompts::{
|
||||||
DEFAULT_IMAGE_PROCESSING_PROMPT, DEFAULT_INGRESS_ANALYSIS_SYSTEM_PROMPT,
|
DEFAULT_IMAGE_PROCESSING_PROMPT, DEFAULT_INGRESS_ANALYSIS_SYSTEM_PROMPT,
|
||||||
@@ -18,7 +17,6 @@ use common::{
|
|||||||
},
|
},
|
||||||
system_settings::SystemSettings,
|
system_settings::SystemSettings,
|
||||||
text_chunk::TextChunk,
|
text_chunk::TextChunk,
|
||||||
user::User,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
@@ -33,13 +31,11 @@ use crate::{
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct AdminPanelData {
|
pub struct AdminPanelData {
|
||||||
user: User,
|
|
||||||
settings: SystemSettings,
|
settings: SystemSettings,
|
||||||
analytics: Option<Analytics>,
|
analytics: Option<Analytics>,
|
||||||
users: Option<i64>,
|
users: Option<i64>,
|
||||||
default_query_prompt: String,
|
default_query_prompt: String,
|
||||||
default_image_prompt: String,
|
default_image_prompt: String,
|
||||||
conversation_archive: Vec<Conversation>,
|
|
||||||
available_models: Option<ListModelResponse>,
|
available_models: Option<ListModelResponse>,
|
||||||
current_section: AdminSection,
|
current_section: AdminSection,
|
||||||
}
|
}
|
||||||
@@ -64,7 +60,7 @@ pub struct AdminPanelQuery {
|
|||||||
|
|
||||||
pub async fn show_admin_panel(
|
pub async fn show_admin_panel(
|
||||||
State(state): State<HtmlState>,
|
State(state): State<HtmlState>,
|
||||||
RequireUser(user): RequireUser,
|
RequireUser(_user): RequireUser,
|
||||||
Query(query): Query<AdminPanelQuery>,
|
Query(query): Query<AdminPanelQuery>,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
let section = match query.section.as_deref() {
|
let section = match query.section.as_deref() {
|
||||||
@@ -72,10 +68,7 @@ pub async fn show_admin_panel(
|
|||||||
_ => AdminSection::Overview,
|
_ => AdminSection::Overview,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (settings, conversation_archive) = tokio::try_join!(
|
let settings = SystemSettings::get_current(&state.db).await?;
|
||||||
SystemSettings::get_current(&state.db),
|
|
||||||
User::get_user_conversations(&user.id, &state.db)
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let (analytics, users) = if section == AdminSection::Overview {
|
let (analytics, users) = if section == AdminSection::Overview {
|
||||||
let (analytics, users) = tokio::try_join!(
|
let (analytics, users) = tokio::try_join!(
|
||||||
@@ -103,14 +96,12 @@ pub async fn show_admin_panel(
|
|||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"admin/base.html",
|
"admin/base.html",
|
||||||
AdminPanelData {
|
AdminPanelData {
|
||||||
user,
|
|
||||||
settings,
|
settings,
|
||||||
analytics,
|
analytics,
|
||||||
available_models,
|
available_models,
|
||||||
users,
|
users,
|
||||||
default_query_prompt: DEFAULT_QUERY_SYSTEM_PROMPT.to_string(),
|
default_query_prompt: DEFAULT_QUERY_SYSTEM_PROMPT.to_string(),
|
||||||
default_image_prompt: DEFAULT_IMAGE_PROCESSING_PROMPT.to_string(),
|
default_image_prompt: DEFAULT_IMAGE_PROCESSING_PROMPT.to_string(),
|
||||||
conversation_archive,
|
|
||||||
current_section: section,
|
current_section: section,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -172,7 +163,7 @@ pub struct ModelSettingsInput {
|
|||||||
processing_model: String,
|
processing_model: String,
|
||||||
image_processing_model: String,
|
image_processing_model: String,
|
||||||
voice_processing_model: String,
|
voice_processing_model: String,
|
||||||
embedding_model: String,
|
embedding_model: Option<String>,
|
||||||
embedding_dimensions: Option<u32>,
|
embedding_dimensions: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +210,9 @@ pub async fn update_model_settings(
|
|||||||
.embedding_dimensions
|
.embedding_dimensions
|
||||||
.is_some_and(|new_dims| new_dims != current_settings.embedding_dimensions);
|
.is_some_and(|new_dims| new_dims != current_settings.embedding_dimensions);
|
||||||
(
|
(
|
||||||
input.embedding_model,
|
input
|
||||||
|
.embedding_model
|
||||||
|
.unwrap_or_else(|| current_settings.embedding_model.clone()),
|
||||||
input
|
input
|
||||||
.embedding_dimensions
|
.embedding_dimensions
|
||||||
.unwrap_or(current_settings.embedding_dimensions),
|
.unwrap_or(current_settings.embedding_dimensions),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
use axum_htmx::HxBoosted;
|
use axum_htmx::HxBoosted;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use common::storage::types::user::User;
|
use common::storage::types::user::{Theme, User};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
html_state::HtmlState,
|
html_state::HtmlState,
|
||||||
@@ -45,7 +45,15 @@ pub async fn process_signup_and_show_verification(
|
|||||||
auth: AuthSessionType,
|
auth: AuthSessionType,
|
||||||
Form(form): Form<SignupParams>,
|
Form(form): Form<SignupParams>,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
let user = match User::create_new(form.email, form.password, &state.db, form.timezone).await {
|
let user = match User::create_new(
|
||||||
|
form.email,
|
||||||
|
form.password,
|
||||||
|
&state.db,
|
||||||
|
form.timezone,
|
||||||
|
Theme::System.as_str().to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(user) => user,
|
Ok(user) => user,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("{:?}", e);
|
tracing::error!("{:?}", e);
|
||||||
|
|||||||
@@ -45,10 +45,8 @@ where
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ChatPageData {
|
pub struct ChatPageData {
|
||||||
user: User,
|
|
||||||
history: Vec<Message>,
|
history: Vec<Message>,
|
||||||
conversation: Option<Conversation>,
|
conversation: Option<Conversation>,
|
||||||
conversation_archive: Vec<Conversation>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn show_initialized_chat(
|
pub async fn show_initialized_chat(
|
||||||
@@ -76,16 +74,12 @@ pub async fn show_initialized_chat(
|
|||||||
state.db.store_item(ai_message.clone()).await?;
|
state.db.store_item(ai_message.clone()).await?;
|
||||||
state.db.store_item(user_message.clone()).await?;
|
state.db.store_item(user_message.clone()).await?;
|
||||||
|
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
let messages = vec![user_message, ai_message];
|
let messages = vec![user_message, ai_message];
|
||||||
|
|
||||||
let mut response = TemplateResponse::new_template(
|
let mut response = TemplateResponse::new_template(
|
||||||
"chat/base.html",
|
"chat/base.html",
|
||||||
ChatPageData {
|
ChatPageData {
|
||||||
history: messages,
|
history: messages,
|
||||||
user,
|
|
||||||
conversation_archive,
|
|
||||||
conversation: Some(conversation.clone()),
|
conversation: Some(conversation.clone()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -100,17 +94,13 @@ pub async fn show_initialized_chat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn show_chat_base(
|
pub async fn show_chat_base(
|
||||||
State(state): State<HtmlState>,
|
State(_state): State<HtmlState>,
|
||||||
RequireUser(user): RequireUser,
|
RequireUser(_user): RequireUser,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"chat/base.html",
|
"chat/base.html",
|
||||||
ChatPageData {
|
ChatPageData {
|
||||||
history: vec![],
|
history: vec![],
|
||||||
user,
|
|
||||||
conversation_archive,
|
|
||||||
conversation: None,
|
conversation: None,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -126,8 +116,6 @@ pub async fn show_existing_chat(
|
|||||||
State(state): State<HtmlState>,
|
State(state): State<HtmlState>,
|
||||||
RequireUser(user): RequireUser,
|
RequireUser(user): RequireUser,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
let (conversation, messages) =
|
let (conversation, messages) =
|
||||||
Conversation::get_complete_conversation(conversation_id.as_str(), &user.id, &state.db)
|
Conversation::get_complete_conversation(conversation_id.as_str(), &user.id, &state.db)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -136,9 +124,7 @@ pub async fn show_existing_chat(
|
|||||||
"chat/base.html",
|
"chat/base.html",
|
||||||
ChatPageData {
|
ChatPageData {
|
||||||
history: messages,
|
history: messages,
|
||||||
user,
|
|
||||||
conversation: Some(conversation),
|
conversation: Some(conversation),
|
||||||
conversation_archive,
|
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -232,8 +218,6 @@ pub struct PatchConversationTitle {
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct DrawerContext {
|
pub struct DrawerContext {
|
||||||
user: User,
|
|
||||||
conversation_archive: Vec<Conversation>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
edit_conversation_id: Option<String>,
|
edit_conversation_id: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -242,20 +226,19 @@ pub async fn show_conversation_editing_title(
|
|||||||
RequireUser(user): RequireUser,
|
RequireUser(user): RequireUser,
|
||||||
Path(conversation_id): Path<String>,
|
Path(conversation_id): Path<String>,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
let conversation: Conversation = state
|
||||||
|
.db
|
||||||
|
.get_item(&conversation_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Conversation not found".to_string()))?;
|
||||||
|
|
||||||
let owns = conversation_archive
|
if conversation.user_id != user.id {
|
||||||
.iter()
|
|
||||||
.any(|c| c.id == conversation_id && c.user_id == user.id);
|
|
||||||
if !owns {
|
|
||||||
return Ok(TemplateResponse::unauthorized().into_response());
|
return Ok(TemplateResponse::unauthorized().into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"sidebar.html",
|
"sidebar.html",
|
||||||
DrawerContext {
|
DrawerContext {
|
||||||
user,
|
|
||||||
conversation_archive,
|
|
||||||
edit_conversation_id: Some(conversation_id),
|
edit_conversation_id: Some(conversation_id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -270,13 +253,9 @@ pub async fn patch_conversation_title(
|
|||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
Conversation::patch_title(&conversation_id, &user.id, &form.title, &state.db).await?;
|
Conversation::patch_title(&conversation_id, &user.id, &form.title, &state.db).await?;
|
||||||
|
|
||||||
let updated_conversations = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"sidebar.html",
|
"sidebar.html",
|
||||||
DrawerContext {
|
DrawerContext {
|
||||||
user,
|
|
||||||
conversation_archive: updated_conversations,
|
|
||||||
edit_conversation_id: None,
|
edit_conversation_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -303,29 +282,21 @@ pub async fn delete_conversation(
|
|||||||
.delete_item::<Conversation>(&conversation_id)
|
.delete_item::<Conversation>(&conversation_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"sidebar.html",
|
"sidebar.html",
|
||||||
DrawerContext {
|
DrawerContext {
|
||||||
user,
|
|
||||||
conversation_archive,
|
|
||||||
edit_conversation_id: None,
|
edit_conversation_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
pub async fn reload_sidebar(
|
pub async fn reload_sidebar(
|
||||||
State(state): State<HtmlState>,
|
State(_state): State<HtmlState>,
|
||||||
RequireUser(user): RequireUser,
|
RequireUser(_user): RequireUser,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"sidebar.html",
|
"sidebar.html",
|
||||||
DrawerContext {
|
DrawerContext {
|
||||||
user,
|
|
||||||
conversation_archive,
|
|
||||||
edit_conversation_id: None,
|
edit_conversation_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use axum_htmx::{HxBoosted, HxRequest, HxTarget};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use common::storage::types::{
|
use common::storage::types::{
|
||||||
conversation::Conversation, file_info::FileInfo, knowledge_entity::KnowledgeEntity,
|
file_info::FileInfo, knowledge_entity::KnowledgeEntity, text_chunk::TextChunk,
|
||||||
text_chunk::TextChunk, text_content::TextContent, user::User,
|
text_content::TextContent, user::User,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -26,18 +26,15 @@ const CONTENTS_PER_PAGE: usize = 12;
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ContentPageData {
|
pub struct ContentPageData {
|
||||||
user: User,
|
|
||||||
text_contents: Vec<TextContent>,
|
text_contents: Vec<TextContent>,
|
||||||
categories: Vec<String>,
|
categories: Vec<String>,
|
||||||
selected_category: Option<String>,
|
selected_category: Option<String>,
|
||||||
conversation_archive: Vec<Conversation>,
|
|
||||||
pagination: Pagination,
|
pagination: Pagination,
|
||||||
page_query: String,
|
page_query: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct RecentTextContentData {
|
pub struct RecentTextContentData {
|
||||||
pub user: User,
|
|
||||||
pub text_contents: Vec<TextContent>,
|
pub text_contents: Vec<TextContent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,13 +78,10 @@ pub async fn show_content_page(
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
let data = ContentPageData {
|
let data = ContentPageData {
|
||||||
user,
|
|
||||||
text_contents,
|
text_contents,
|
||||||
categories,
|
categories,
|
||||||
selected_category: params.category.clone(),
|
selected_category: params.category.clone(),
|
||||||
conversation_archive,
|
|
||||||
pagination,
|
pagination,
|
||||||
page_query,
|
page_query,
|
||||||
};
|
};
|
||||||
@@ -112,13 +106,12 @@ pub async fn show_text_content_edit_form(
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TextContentEditModal {
|
pub struct TextContentEditModal {
|
||||||
pub user: User,
|
|
||||||
pub text_content: TextContent,
|
pub text_content: TextContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"content/edit_text_content_modal.html",
|
"content/edit_text_content_modal.html",
|
||||||
TextContentEditModal { user, text_content },
|
TextContentEditModal { text_content },
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,10 +138,7 @@ pub async fn patch_text_content(
|
|||||||
|
|
||||||
return Ok(TemplateResponse::new_template(
|
return Ok(TemplateResponse::new_template(
|
||||||
"dashboard/recent_content.html",
|
"dashboard/recent_content.html",
|
||||||
RecentTextContentData {
|
RecentTextContentData { text_contents },
|
||||||
user,
|
|
||||||
text_contents,
|
|
||||||
},
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,17 +149,14 @@ pub async fn patch_text_content(
|
|||||||
);
|
);
|
||||||
let text_contents = truncate_text_contents(page_contents);
|
let text_contents = truncate_text_contents(page_contents);
|
||||||
let categories = User::get_user_categories(&user.id, &state.db).await?;
|
let categories = User::get_user_categories(&user.id, &state.db).await?;
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
Ok(TemplateResponse::new_partial(
|
Ok(TemplateResponse::new_partial(
|
||||||
"content/base.html",
|
"content/base.html",
|
||||||
"main",
|
"main",
|
||||||
ContentPageData {
|
ContentPageData {
|
||||||
user,
|
|
||||||
text_contents,
|
text_contents,
|
||||||
categories,
|
categories,
|
||||||
selected_category: None,
|
selected_category: None,
|
||||||
conversation_archive,
|
|
||||||
pagination,
|
pagination,
|
||||||
page_query: String::new(),
|
page_query: String::new(),
|
||||||
},
|
},
|
||||||
@@ -209,16 +196,13 @@ pub async fn delete_text_content(
|
|||||||
);
|
);
|
||||||
let text_contents = truncate_text_contents(page_contents);
|
let text_contents = truncate_text_contents(page_contents);
|
||||||
let categories = User::get_user_categories(&user.id, &state.db).await?;
|
let categories = User::get_user_categories(&user.id, &state.db).await?;
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"content/content_list.html",
|
"content/content_list.html",
|
||||||
ContentPageData {
|
ContentPageData {
|
||||||
user,
|
|
||||||
text_contents,
|
text_contents,
|
||||||
categories,
|
categories,
|
||||||
selected_category: None,
|
selected_category: None,
|
||||||
conversation_archive,
|
|
||||||
pagination,
|
pagination,
|
||||||
page_query: String::new(),
|
page_query: String::new(),
|
||||||
},
|
},
|
||||||
@@ -234,13 +218,12 @@ pub async fn show_content_read_modal(
|
|||||||
let text_content = User::get_and_validate_text_content(&id, &user.id, &state.db).await?;
|
let text_content = User::get_and_validate_text_content(&id, &user.id, &state.db).await?;
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct TextContentReadModalData {
|
pub struct TextContentReadModalData {
|
||||||
pub user: User,
|
|
||||||
pub text_content: TextContent,
|
pub text_content: TextContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"content/read_content_modal.html",
|
"content/read_content_modal.html",
|
||||||
TextContentReadModalData { user, text_content },
|
TextContentReadModalData { text_content },
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,9 +236,6 @@ pub async fn show_recent_content(
|
|||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"dashboard/recent_content.html",
|
"dashboard/recent_content.html",
|
||||||
RecentTextContentData {
|
RecentTextContentData { text_contents },
|
||||||
user,
|
|
||||||
text_contents,
|
|
||||||
},
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,19 +21,17 @@ use common::storage::types::user::DashboardStats;
|
|||||||
use common::{
|
use common::{
|
||||||
error::AppError,
|
error::AppError,
|
||||||
storage::types::{
|
storage::types::{
|
||||||
conversation::Conversation, file_info::FileInfo, ingestion_task::IngestionTask,
|
file_info::FileInfo, ingestion_task::IngestionTask, knowledge_entity::KnowledgeEntity,
|
||||||
knowledge_entity::KnowledgeEntity, knowledge_relationship::KnowledgeRelationship,
|
knowledge_relationship::KnowledgeRelationship, text_chunk::TextChunk,
|
||||||
text_chunk::TextChunk, text_content::TextContent, user::User,
|
text_content::TextContent, user::User,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct IndexPageData {
|
pub struct IndexPageData {
|
||||||
user: Option<User>,
|
|
||||||
text_contents: Vec<TextContent>,
|
text_contents: Vec<TextContent>,
|
||||||
stats: DashboardStats,
|
stats: DashboardStats,
|
||||||
active_jobs: Vec<IngestionTask>,
|
active_jobs: Vec<IngestionTask>,
|
||||||
conversation_archive: Vec<Conversation>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn index_handler(
|
pub async fn index_handler(
|
||||||
@@ -44,7 +42,7 @@ pub async fn index_handler(
|
|||||||
return Ok(TemplateResponse::redirect("/signin"));
|
return Ok(TemplateResponse::redirect("/signin"));
|
||||||
};
|
};
|
||||||
|
|
||||||
let (text_contents, conversation_archive, stats, active_jobs) = try_join!(
|
let (text_contents, _conversation_archive, stats, active_jobs) = try_join!(
|
||||||
User::get_latest_text_contents(&user.id, &state.db),
|
User::get_latest_text_contents(&user.id, &state.db),
|
||||||
User::get_user_conversations(&user.id, &state.db),
|
User::get_user_conversations(&user.id, &state.db),
|
||||||
User::get_dashboard_stats(&user.id, &state.db),
|
User::get_dashboard_stats(&user.id, &state.db),
|
||||||
@@ -56,10 +54,8 @@ pub async fn index_handler(
|
|||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"dashboard/base.html",
|
"dashboard/base.html",
|
||||||
IndexPageData {
|
IndexPageData {
|
||||||
user: Some(user),
|
|
||||||
text_contents,
|
text_contents,
|
||||||
stats,
|
stats,
|
||||||
conversation_archive,
|
|
||||||
active_jobs,
|
active_jobs,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -68,7 +64,6 @@ pub async fn index_handler(
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct LatestTextContentData {
|
pub struct LatestTextContentData {
|
||||||
text_contents: Vec<TextContent>,
|
text_contents: Vec<TextContent>,
|
||||||
user: User,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_text_content(
|
pub async fn delete_text_content(
|
||||||
@@ -105,10 +100,7 @@ pub async fn delete_text_content(
|
|||||||
Ok(TemplateResponse::new_partial(
|
Ok(TemplateResponse::new_partial(
|
||||||
"dashboard/recent_content.html",
|
"dashboard/recent_content.html",
|
||||||
"latest_content_section",
|
"latest_content_section",
|
||||||
LatestTextContentData {
|
LatestTextContentData { text_contents },
|
||||||
user: user.clone(),
|
|
||||||
text_contents,
|
|
||||||
},
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +128,6 @@ async fn get_and_validate_text_content(
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ActiveJobsData {
|
pub struct ActiveJobsData {
|
||||||
pub active_jobs: Vec<IngestionTask>,
|
pub active_jobs: Vec<IngestionTask>,
|
||||||
pub user: User,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -161,7 +152,6 @@ struct TaskArchiveEntry {
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct TaskArchiveData {
|
struct TaskArchiveData {
|
||||||
user: User,
|
|
||||||
tasks: Vec<TaskArchiveEntry>,
|
tasks: Vec<TaskArchiveEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,10 +167,7 @@ pub async fn delete_job(
|
|||||||
Ok(TemplateResponse::new_partial(
|
Ok(TemplateResponse::new_partial(
|
||||||
"dashboard/active_jobs.html",
|
"dashboard/active_jobs.html",
|
||||||
"active_jobs_section",
|
"active_jobs_section",
|
||||||
ActiveJobsData {
|
ActiveJobsData { active_jobs },
|
||||||
user: user.clone(),
|
|
||||||
active_jobs,
|
|
||||||
},
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,10 +179,7 @@ pub async fn show_active_jobs(
|
|||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"dashboard/active_jobs.html",
|
"dashboard/active_jobs.html",
|
||||||
ActiveJobsData {
|
ActiveJobsData { active_jobs },
|
||||||
user: user.clone(),
|
|
||||||
active_jobs,
|
|
||||||
},
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,10 +217,7 @@ pub async fn show_task_archive(
|
|||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"dashboard/task_archive_modal.html",
|
"dashboard/task_archive_modal.html",
|
||||||
TaskArchiveData {
|
TaskArchiveData { tasks: entries },
|
||||||
user,
|
|
||||||
tasks: entries,
|
|
||||||
},
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,13 +120,12 @@ pub async fn process_ingress_form(
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct NewTasksData {
|
struct NewTasksData {
|
||||||
user: User,
|
|
||||||
tasks: Vec<IngestionTask>,
|
tasks: Vec<IngestionTask>,
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"dashboard/current_task.html",
|
"dashboard/current_task.html",
|
||||||
NewTasksData { user, tasks },
|
NewTasksData { tasks },
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ use serde::{
|
|||||||
use common::{
|
use common::{
|
||||||
error::AppError,
|
error::AppError,
|
||||||
storage::types::{
|
storage::types::{
|
||||||
conversation::Conversation,
|
|
||||||
knowledge_entity::{KnowledgeEntity, KnowledgeEntityType},
|
knowledge_entity::{KnowledgeEntity, KnowledgeEntityType},
|
||||||
knowledge_relationship::KnowledgeRelationship,
|
knowledge_relationship::KnowledgeRelationship,
|
||||||
user::User,
|
user::User,
|
||||||
@@ -333,12 +332,10 @@ pub struct KnowledgeBaseData {
|
|||||||
entities: Vec<KnowledgeEntity>,
|
entities: Vec<KnowledgeEntity>,
|
||||||
visible_entities: Vec<KnowledgeEntity>,
|
visible_entities: Vec<KnowledgeEntity>,
|
||||||
relationships: Vec<RelationshipTableRow>,
|
relationships: Vec<RelationshipTableRow>,
|
||||||
user: User,
|
|
||||||
entity_types: Vec<String>,
|
entity_types: Vec<String>,
|
||||||
content_categories: Vec<String>,
|
content_categories: Vec<String>,
|
||||||
selected_entity_type: Option<String>,
|
selected_entity_type: Option<String>,
|
||||||
selected_content_category: Option<String>,
|
selected_content_category: Option<String>,
|
||||||
conversation_archive: Vec<Conversation>,
|
|
||||||
pagination: Pagination,
|
pagination: Pagination,
|
||||||
page_query: String,
|
page_query: String,
|
||||||
relationship_type_options: Vec<String>,
|
relationship_type_options: Vec<String>,
|
||||||
@@ -481,18 +478,15 @@ async fn build_knowledge_base_data(
|
|||||||
relationship_type_options,
|
relationship_type_options,
|
||||||
default_relationship_type,
|
default_relationship_type,
|
||||||
} = build_relationship_table_data(entities.clone(), filtered_relationships);
|
} = build_relationship_table_data(entities.clone(), filtered_relationships);
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
Ok(KnowledgeBaseData {
|
Ok(KnowledgeBaseData {
|
||||||
entities,
|
entities,
|
||||||
visible_entities,
|
visible_entities,
|
||||||
relationships,
|
relationships,
|
||||||
user: user.clone(),
|
|
||||||
entity_types,
|
entity_types,
|
||||||
content_categories,
|
content_categories,
|
||||||
selected_entity_type: params.entity_type.clone(),
|
selected_entity_type: params.entity_type.clone(),
|
||||||
selected_content_category: params.content_category.clone(),
|
selected_content_category: params.content_category.clone(),
|
||||||
conversation_archive,
|
|
||||||
pagination,
|
pagination,
|
||||||
page_query,
|
page_query,
|
||||||
relationship_type_options,
|
relationship_type_options,
|
||||||
@@ -861,7 +855,6 @@ pub async fn show_edit_knowledge_entity_form(
|
|||||||
pub struct EntityData {
|
pub struct EntityData {
|
||||||
entity: KnowledgeEntity,
|
entity: KnowledgeEntity,
|
||||||
entity_types: Vec<String>,
|
entity_types: Vec<String>,
|
||||||
user: User,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get entity types
|
// Get entity types
|
||||||
@@ -878,7 +871,6 @@ pub async fn show_edit_knowledge_entity_form(
|
|||||||
EntityData {
|
EntityData {
|
||||||
entity,
|
entity,
|
||||||
entity_types,
|
entity_types,
|
||||||
user,
|
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -895,7 +887,6 @@ pub struct PatchKnowledgeEntityParams {
|
|||||||
pub struct EntityListData {
|
pub struct EntityListData {
|
||||||
visible_entities: Vec<KnowledgeEntity>,
|
visible_entities: Vec<KnowledgeEntity>,
|
||||||
pagination: Pagination,
|
pagination: Pagination,
|
||||||
user: User,
|
|
||||||
entity_types: Vec<String>,
|
entity_types: Vec<String>,
|
||||||
content_categories: Vec<String>,
|
content_categories: Vec<String>,
|
||||||
selected_entity_type: Option<String>,
|
selected_entity_type: Option<String>,
|
||||||
@@ -943,7 +934,6 @@ pub async fn patch_knowledge_entity(
|
|||||||
EntityListData {
|
EntityListData {
|
||||||
visible_entities,
|
visible_entities,
|
||||||
pagination,
|
pagination,
|
||||||
user,
|
|
||||||
entity_types,
|
entity_types,
|
||||||
content_categories,
|
content_categories,
|
||||||
selected_entity_type: None,
|
selected_entity_type: None,
|
||||||
@@ -982,7 +972,6 @@ pub async fn delete_knowledge_entity(
|
|||||||
EntityListData {
|
EntityListData {
|
||||||
visible_entities,
|
visible_entities,
|
||||||
pagination,
|
pagination,
|
||||||
user,
|
|
||||||
entity_types,
|
entity_types,
|
||||||
content_categories,
|
content_categories,
|
||||||
selected_entity_type: None,
|
selected_entity_type: None,
|
||||||
|
|||||||
@@ -14,16 +14,13 @@ use crate::middlewares::{
|
|||||||
response_middleware::{HtmlError, TemplateResponse},
|
response_middleware::{HtmlError, TemplateResponse},
|
||||||
};
|
};
|
||||||
use common::storage::types::{
|
use common::storage::types::{
|
||||||
conversation::Conversation, ingestion_payload::IngestionPayload, ingestion_task::IngestionTask,
|
ingestion_payload::IngestionPayload, ingestion_task::IngestionTask, scratchpad::Scratchpad,
|
||||||
scratchpad::Scratchpad, user::User,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ScratchpadPageData {
|
pub struct ScratchpadPageData {
|
||||||
user: User,
|
|
||||||
scratchpads: Vec<ScratchpadListItem>,
|
scratchpads: Vec<ScratchpadListItem>,
|
||||||
archived_scratchpads: Vec<ScratchpadArchiveItem>,
|
archived_scratchpads: Vec<ScratchpadArchiveItem>,
|
||||||
conversation_archive: Vec<Conversation>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
new_scratchpad: Option<ScratchpadDetail>,
|
new_scratchpad: Option<ScratchpadDetail>,
|
||||||
}
|
}
|
||||||
@@ -38,9 +35,8 @@ pub struct ScratchpadListItem {
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ScratchpadDetailData {
|
pub struct ScratchpadDetailData {
|
||||||
user: User,
|
|
||||||
scratchpad: ScratchpadDetail,
|
scratchpad: ScratchpadDetail,
|
||||||
conversation_archive: Vec<Conversation>,
|
is_editing_title: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -134,7 +130,6 @@ pub async fn show_scratchpad_page(
|
|||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||||
@@ -148,10 +143,8 @@ pub async fn show_scratchpad_page(
|
|||||||
"scratchpad/base.html",
|
"scratchpad/base.html",
|
||||||
"main",
|
"main",
|
||||||
ScratchpadPageData {
|
ScratchpadPageData {
|
||||||
user,
|
|
||||||
scratchpads: scratchpad_list,
|
scratchpads: scratchpad_list,
|
||||||
archived_scratchpads: archived_list,
|
archived_scratchpads: archived_list,
|
||||||
conversation_archive,
|
|
||||||
new_scratchpad: None,
|
new_scratchpad: None,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -159,10 +152,8 @@ pub async fn show_scratchpad_page(
|
|||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"scratchpad/base.html",
|
"scratchpad/base.html",
|
||||||
ScratchpadPageData {
|
ScratchpadPageData {
|
||||||
user,
|
|
||||||
scratchpads: scratchpad_list,
|
scratchpads: scratchpad_list,
|
||||||
archived_scratchpads: archived_list,
|
archived_scratchpads: archived_list,
|
||||||
conversation_archive,
|
|
||||||
new_scratchpad: None,
|
new_scratchpad: None,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -176,19 +167,17 @@ pub async fn show_scratchpad_modal(
|
|||||||
Query(query): Query<EditTitleQuery>,
|
Query(query): Query<EditTitleQuery>,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
let scratchpad = Scratchpad::get_by_id(&scratchpad_id, &user.id, &state.db).await?;
|
let scratchpad = Scratchpad::get_by_id(&scratchpad_id, &user.id, &state.db).await?;
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
let scratchpad_detail = ScratchpadDetail::from(&scratchpad);
|
let scratchpad_detail = ScratchpadDetail::from(&scratchpad);
|
||||||
|
|
||||||
// Handle edit_title query parameter if needed in future
|
// Handle edit_title query parameter
|
||||||
let _ = query.edit_title.unwrap_or(false);
|
let is_editing_title = query.edit_title.unwrap_or(false);
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"scratchpad/editor_modal.html",
|
"scratchpad/editor_modal.html",
|
||||||
ScratchpadDetailData {
|
ScratchpadDetailData {
|
||||||
user,
|
|
||||||
scratchpad: scratchpad_detail,
|
scratchpad: scratchpad_detail,
|
||||||
conversation_archive,
|
is_editing_title,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -204,7 +193,6 @@ pub async fn create_scratchpad(
|
|||||||
|
|
||||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||||
@@ -217,10 +205,8 @@ pub async fn create_scratchpad(
|
|||||||
"scratchpad/base.html",
|
"scratchpad/base.html",
|
||||||
"main",
|
"main",
|
||||||
ScratchpadPageData {
|
ScratchpadPageData {
|
||||||
user,
|
|
||||||
scratchpads: scratchpad_list,
|
scratchpads: scratchpad_list,
|
||||||
archived_scratchpads: archived_list,
|
archived_scratchpads: archived_list,
|
||||||
conversation_archive,
|
|
||||||
new_scratchpad: Some(ScratchpadDetail::from(&scratchpad)),
|
new_scratchpad: Some(ScratchpadDetail::from(&scratchpad)),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -255,14 +241,12 @@ pub async fn update_scratchpad_title(
|
|||||||
Scratchpad::update_title(&scratchpad_id, &user.id, &form.title, &state.db).await?;
|
Scratchpad::update_title(&scratchpad_id, &user.id, &form.title, &state.db).await?;
|
||||||
|
|
||||||
let scratchpad = Scratchpad::get_by_id(&scratchpad_id, &user.id, &state.db).await?;
|
let scratchpad = Scratchpad::get_by_id(&scratchpad_id, &user.id, &state.db).await?;
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"scratchpad/editor_modal.html",
|
"scratchpad/editor_modal.html",
|
||||||
ScratchpadDetailData {
|
ScratchpadDetailData {
|
||||||
user,
|
|
||||||
scratchpad: ScratchpadDetail::from(&scratchpad),
|
scratchpad: ScratchpadDetail::from(&scratchpad),
|
||||||
conversation_archive,
|
is_editing_title: false,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -276,7 +260,6 @@ pub async fn delete_scratchpad(
|
|||||||
|
|
||||||
// Return the updated main section content
|
// Return the updated main section content
|
||||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||||
|
|
||||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||||
@@ -290,10 +273,8 @@ pub async fn delete_scratchpad(
|
|||||||
"scratchpad/base.html",
|
"scratchpad/base.html",
|
||||||
"main",
|
"main",
|
||||||
ScratchpadPageData {
|
ScratchpadPageData {
|
||||||
user,
|
|
||||||
scratchpads: scratchpad_list,
|
scratchpads: scratchpad_list,
|
||||||
archived_scratchpads: archived_list,
|
archived_scratchpads: archived_list,
|
||||||
conversation_archive,
|
|
||||||
new_scratchpad: None,
|
new_scratchpad: None,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
@@ -347,7 +328,6 @@ pub async fn ingest_scratchpad(
|
|||||||
|
|
||||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||||
@@ -371,10 +351,8 @@ pub async fn ingest_scratchpad(
|
|||||||
"scratchpad/base.html",
|
"scratchpad/base.html",
|
||||||
"main",
|
"main",
|
||||||
ScratchpadPageData {
|
ScratchpadPageData {
|
||||||
user,
|
|
||||||
scratchpads: scratchpad_list,
|
scratchpads: scratchpad_list,
|
||||||
archived_scratchpads: archived_list,
|
archived_scratchpads: archived_list,
|
||||||
conversation_archive,
|
|
||||||
new_scratchpad: None,
|
new_scratchpad: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -396,7 +374,6 @@ pub async fn archive_scratchpad(
|
|||||||
|
|
||||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||||
@@ -408,15 +385,59 @@ pub async fn archive_scratchpad(
|
|||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"scratchpad/base.html",
|
"scratchpad/base.html",
|
||||||
ScratchpadPageData {
|
ScratchpadPageData {
|
||||||
user,
|
|
||||||
scratchpads: scratchpad_list,
|
scratchpads: scratchpad_list,
|
||||||
archived_scratchpads: archived_list,
|
archived_scratchpads: archived_list,
|
||||||
conversation_archive,
|
|
||||||
new_scratchpad: None,
|
new_scratchpad: None,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn restore_scratchpad(
|
||||||
|
RequireUser(user): RequireUser,
|
||||||
|
State(state): State<HtmlState>,
|
||||||
|
Path(scratchpad_id): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
|
Scratchpad::restore(&scratchpad_id, &user.id, &state.db).await?;
|
||||||
|
|
||||||
|
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||||
|
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||||
|
|
||||||
|
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||||
|
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||||
|
let archived_list: Vec<ScratchpadArchiveItem> = archived_scratchpads
|
||||||
|
.iter()
|
||||||
|
.map(ScratchpadArchiveItem::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let trigger_payload = serde_json::json!({
|
||||||
|
"toast": {
|
||||||
|
"title": "Scratchpad restored",
|
||||||
|
"description": "The scratchpad is back in your active list.",
|
||||||
|
"type": "info"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|_| {
|
||||||
|
r#"{"toast":{"title":"Scratchpad restored","description":"The scratchpad is back in your active list.","type":"info"}}"#.to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let template_response = TemplateResponse::new_partial(
|
||||||
|
"scratchpad/base.html",
|
||||||
|
"main",
|
||||||
|
ScratchpadPageData {
|
||||||
|
scratchpads: scratchpad_list,
|
||||||
|
archived_scratchpads: archived_list,
|
||||||
|
new_scratchpad: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut response = template_response.into_response();
|
||||||
|
if let Ok(header_value) = HeaderValue::from_str(&trigger_value) {
|
||||||
|
response.headers_mut().insert(HX_TRIGGER, header_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -506,52 +527,3 @@ mod tests {
|
|||||||
assert_eq!(archive_item.ingested_at, None);
|
assert_eq!(archive_item.ingested_at, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn restore_scratchpad(
|
|
||||||
RequireUser(user): RequireUser,
|
|
||||||
State(state): State<HtmlState>,
|
|
||||||
Path(scratchpad_id): Path<String>,
|
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
|
||||||
Scratchpad::restore(&scratchpad_id, &user.id, &state.db).await?;
|
|
||||||
|
|
||||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
|
||||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
|
||||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
|
||||||
let archived_list: Vec<ScratchpadArchiveItem> = archived_scratchpads
|
|
||||||
.iter()
|
|
||||||
.map(ScratchpadArchiveItem::from)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let trigger_payload = serde_json::json!({
|
|
||||||
"toast": {
|
|
||||||
"title": "Scratchpad restored",
|
|
||||||
"description": "The scratchpad is back in your active list.",
|
|
||||||
"type": "info"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|_| {
|
|
||||||
r#"{"toast":{"title":"Scratchpad restored","description":"The scratchpad is back in your active list.","type":"info"}}"#.to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
let template_response = TemplateResponse::new_partial(
|
|
||||||
"scratchpad/base.html",
|
|
||||||
"main",
|
|
||||||
ScratchpadPageData {
|
|
||||||
user,
|
|
||||||
scratchpads: scratchpad_list,
|
|
||||||
archived_scratchpads: archived_list,
|
|
||||||
conversation_archive,
|
|
||||||
new_scratchpad: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut response = template_response.into_response();
|
|
||||||
if let Ok(header_value) = HeaderValue::from_str(&trigger_value) {
|
|
||||||
response.headers_mut().insert(HX_TRIGGER, header_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
fmt, str::FromStr,
|
fmt,
|
||||||
|
str::FromStr,
|
||||||
};
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -8,9 +9,7 @@ use axum::{
|
|||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use common::storage::types::{
|
use common::storage::types::{
|
||||||
conversation::Conversation,
|
|
||||||
text_content::{deserialize_flexible_id, TextContent},
|
text_content::{deserialize_flexible_id, TextContent},
|
||||||
user::User,
|
|
||||||
StoredObject,
|
StoredObject,
|
||||||
};
|
};
|
||||||
use retrieval_pipeline::{RetrievalConfig, SearchResult, SearchTarget, StrategyOutput};
|
use retrieval_pipeline::{RetrievalConfig, SearchResult, SearchTarget, StrategyOutput};
|
||||||
@@ -193,64 +192,62 @@ pub async fn search_result_handler(
|
|||||||
pub struct AnswerData {
|
pub struct AnswerData {
|
||||||
search_result: Vec<SearchResultForTemplate>,
|
search_result: Vec<SearchResultForTemplate>,
|
||||||
query_param: String,
|
query_param: String,
|
||||||
user: User,
|
|
||||||
conversation_archive: Vec<Conversation>,
|
|
||||||
}
|
}
|
||||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
|
||||||
|
|
||||||
let (search_results_for_template, final_query_param_for_template) =
|
let (search_results_for_template, final_query_param_for_template) = if let Some(actual_query) =
|
||||||
if let Some(actual_query) = params.query {
|
params.query
|
||||||
let trimmed_query = actual_query.trim();
|
{
|
||||||
if trimmed_query.is_empty() {
|
let trimmed_query = actual_query.trim();
|
||||||
(Vec::<SearchResultForTemplate>::new(), String::new())
|
if trimmed_query.is_empty() {
|
||||||
|
(Vec::<SearchResultForTemplate>::new(), String::new())
|
||||||
|
} else {
|
||||||
|
// Use retrieval pipeline Search strategy
|
||||||
|
let config = RetrievalConfig::for_search(SearchTarget::Both);
|
||||||
|
|
||||||
|
// Checkout a reranker lease if pool is available
|
||||||
|
let reranker_lease = match &state.reranker_pool {
|
||||||
|
Some(pool) => Some(pool.checkout().await),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = retrieval_pipeline::pipeline::run_pipeline(
|
||||||
|
&state.db,
|
||||||
|
&state.openai_client,
|
||||||
|
Some(&state.embedding_provider),
|
||||||
|
trimmed_query,
|
||||||
|
&user.id,
|
||||||
|
config,
|
||||||
|
reranker_lease,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let search_result = match result {
|
||||||
|
StrategyOutput::Search(sr) => sr,
|
||||||
|
_ => SearchResult::new(vec![], vec![]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut source_ids = HashSet::new();
|
||||||
|
for chunk_result in &search_result.chunks {
|
||||||
|
source_ids.insert(chunk_result.chunk.source_id.clone());
|
||||||
|
}
|
||||||
|
for entity_result in &search_result.entities {
|
||||||
|
source_ids.insert(entity_result.entity.source_id.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_label_map = if source_ids.is_empty() {
|
||||||
|
HashMap::new()
|
||||||
} else {
|
} else {
|
||||||
// Use retrieval pipeline Search strategy
|
let record_ids: Vec<RecordId> = source_ids
|
||||||
let config = RetrievalConfig::for_search(SearchTarget::Both);
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
// Checkout a reranker lease if pool is available
|
if id.contains(':') {
|
||||||
let reranker_lease = match &state.reranker_pool {
|
RecordId::from_str(id).ok()
|
||||||
Some(pool) => Some(pool.checkout().await),
|
} else {
|
||||||
None => None,
|
Some(RecordId::from_table_key(TextContent::table_name(), id))
|
||||||
};
|
}
|
||||||
|
})
|
||||||
let result = retrieval_pipeline::pipeline::run_pipeline(
|
.collect();
|
||||||
&state.db,
|
let mut response = state
|
||||||
&state.openai_client,
|
|
||||||
Some(&state.embedding_provider),
|
|
||||||
trimmed_query,
|
|
||||||
&user.id,
|
|
||||||
config,
|
|
||||||
reranker_lease,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let search_result = match result {
|
|
||||||
StrategyOutput::Search(sr) => sr,
|
|
||||||
_ => SearchResult::new(vec![], vec![]),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut source_ids = HashSet::new();
|
|
||||||
for chunk_result in &search_result.chunks {
|
|
||||||
source_ids.insert(chunk_result.chunk.source_id.clone());
|
|
||||||
}
|
|
||||||
for entity_result in &search_result.entities {
|
|
||||||
source_ids.insert(entity_result.entity.source_id.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let source_label_map = if source_ids.is_empty() {
|
|
||||||
HashMap::new()
|
|
||||||
} else {
|
|
||||||
let record_ids: Vec<RecordId> = source_ids
|
|
||||||
.iter()
|
|
||||||
.filter_map(|id| {
|
|
||||||
if id.contains(':') {
|
|
||||||
RecordId::from_str(id).ok()
|
|
||||||
} else {
|
|
||||||
Some(RecordId::from_table_key(TextContent::table_name(), id))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let mut response = state
|
|
||||||
.db
|
.db
|
||||||
.client
|
.client
|
||||||
.query(
|
.query(
|
||||||
@@ -260,92 +257,90 @@ pub async fn search_result_handler(
|
|||||||
.bind(("user_id", user.id.clone()))
|
.bind(("user_id", user.id.clone()))
|
||||||
.bind(("record_ids", record_ids))
|
.bind(("record_ids", record_ids))
|
||||||
.await?;
|
.await?;
|
||||||
let contents: Vec<SourceLabelRow> = response.take(0)?;
|
let contents: Vec<SourceLabelRow> = response.take(0)?;
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
source_id_count = source_ids.len(),
|
source_id_count = source_ids.len(),
|
||||||
label_row_count = contents.len(),
|
label_row_count = contents.len(),
|
||||||
"Resolved search source labels"
|
"Resolved search source labels"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut labels = HashMap::new();
|
||||||
|
for content in contents {
|
||||||
|
let label = build_source_label(&content);
|
||||||
|
labels.insert(content.id.clone(), label.clone());
|
||||||
|
labels.insert(
|
||||||
|
format!("{}:{}", TextContent::table_name(), content.id),
|
||||||
|
label,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut labels = HashMap::new();
|
labels
|
||||||
for content in contents {
|
};
|
||||||
let label = build_source_label(&content);
|
|
||||||
labels.insert(content.id.clone(), label.clone());
|
|
||||||
labels.insert(
|
|
||||||
format!("{}:{}", TextContent::table_name(), content.id),
|
|
||||||
label,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
labels
|
let mut combined_results: Vec<SearchResultForTemplate> =
|
||||||
};
|
Vec::with_capacity(search_result.chunks.len() + search_result.entities.len());
|
||||||
|
|
||||||
let mut combined_results: Vec<SearchResultForTemplate> =
|
// Add chunk results
|
||||||
Vec::with_capacity(search_result.chunks.len() + search_result.entities.len());
|
for chunk_result in search_result.chunks {
|
||||||
|
let source_label = source_label_map
|
||||||
// Add chunk results
|
.get(&chunk_result.chunk.source_id)
|
||||||
for chunk_result in search_result.chunks {
|
.cloned()
|
||||||
let source_label = source_label_map
|
.unwrap_or_else(|| fallback_source_label(&chunk_result.chunk.source_id));
|
||||||
.get(&chunk_result.chunk.source_id)
|
combined_results.push(SearchResultForTemplate {
|
||||||
.cloned()
|
result_type: "text_chunk".to_string(),
|
||||||
.unwrap_or_else(|| fallback_source_label(&chunk_result.chunk.source_id));
|
score: chunk_result.score,
|
||||||
combined_results.push(SearchResultForTemplate {
|
text_chunk: Some(TextChunkForTemplate {
|
||||||
result_type: "text_chunk".to_string(),
|
id: chunk_result.chunk.id,
|
||||||
|
source_id: chunk_result.chunk.source_id,
|
||||||
|
source_label,
|
||||||
|
chunk: chunk_result.chunk.chunk,
|
||||||
score: chunk_result.score,
|
score: chunk_result.score,
|
||||||
text_chunk: Some(TextChunkForTemplate {
|
}),
|
||||||
id: chunk_result.chunk.id,
|
knowledge_entity: None,
|
||||||
source_id: chunk_result.chunk.source_id,
|
});
|
||||||
source_label,
|
|
||||||
chunk: chunk_result.chunk.chunk,
|
|
||||||
score: chunk_result.score,
|
|
||||||
}),
|
|
||||||
knowledge_entity: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add entity results
|
|
||||||
for entity_result in search_result.entities {
|
|
||||||
let source_label = source_label_map
|
|
||||||
.get(&entity_result.entity.source_id)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| fallback_source_label(&entity_result.entity.source_id));
|
|
||||||
combined_results.push(SearchResultForTemplate {
|
|
||||||
result_type: "knowledge_entity".to_string(),
|
|
||||||
score: entity_result.score,
|
|
||||||
text_chunk: None,
|
|
||||||
knowledge_entity: Some(KnowledgeEntityForTemplate {
|
|
||||||
id: entity_result.entity.id,
|
|
||||||
name: entity_result.entity.name,
|
|
||||||
description: entity_result.entity.description,
|
|
||||||
entity_type: format!("{:?}", entity_result.entity.entity_type),
|
|
||||||
source_id: entity_result.entity.source_id,
|
|
||||||
source_label,
|
|
||||||
score: entity_result.score,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by score descending
|
|
||||||
combined_results.sort_by(|a, b| b.score.total_cmp(&a.score));
|
|
||||||
|
|
||||||
// Limit results
|
|
||||||
const TOTAL_LIMIT: usize = 10;
|
|
||||||
combined_results.truncate(TOTAL_LIMIT);
|
|
||||||
|
|
||||||
(combined_results, trimmed_query.to_string())
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
(Vec::<SearchResultForTemplate>::new(), String::new())
|
// Add entity results
|
||||||
};
|
for entity_result in search_result.entities {
|
||||||
|
let source_label = source_label_map
|
||||||
|
.get(&entity_result.entity.source_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| fallback_source_label(&entity_result.entity.source_id));
|
||||||
|
combined_results.push(SearchResultForTemplate {
|
||||||
|
result_type: "knowledge_entity".to_string(),
|
||||||
|
score: entity_result.score,
|
||||||
|
text_chunk: None,
|
||||||
|
knowledge_entity: Some(KnowledgeEntityForTemplate {
|
||||||
|
id: entity_result.entity.id,
|
||||||
|
name: entity_result.entity.name,
|
||||||
|
description: entity_result.entity.description,
|
||||||
|
entity_type: format!("{:?}", entity_result.entity.entity_type),
|
||||||
|
source_id: entity_result.entity.source_id,
|
||||||
|
source_label,
|
||||||
|
score: entity_result.score,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
combined_results.sort_by(|a, b| b.score.total_cmp(&a.score));
|
||||||
|
|
||||||
|
// Limit results
|
||||||
|
const TOTAL_LIMIT: usize = 10;
|
||||||
|
combined_results.truncate(TOTAL_LIMIT);
|
||||||
|
|
||||||
|
(combined_results, trimmed_query.to_string())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(Vec::<SearchResultForTemplate>::new(), String::new())
|
||||||
|
};
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"search/base.html",
|
"search/base.html",
|
||||||
AnswerData {
|
AnswerData {
|
||||||
search_result: search_results_for_template,
|
search_result: search_results_for_template,
|
||||||
query_param: final_query_param_for_template,
|
query_param: final_query_param_for_template,
|
||||||
user,
|
|
||||||
conversation_archive,
|
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
20
html-router/templates/admin/_base.html
Normal file
20
html-router/templates/admin/_base.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends 'admin/_layout.html' %}
|
||||||
|
|
||||||
|
{% block admin_navigation %}
|
||||||
|
<a href="/admin?section=overview"
|
||||||
|
class="nb-btn btn-sm px-4 {% if current_section == 'overview' %}nb-cta{% else %}btn-ghost{% endif %}">
|
||||||
|
Overview
|
||||||
|
</a>
|
||||||
|
<a href="/admin?section=models"
|
||||||
|
class="nb-btn btn-sm px-4 {% if current_section == 'models' %}nb-cta{% else %}btn-ghost{% endif %}">
|
||||||
|
Models
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
{% if current_section == 'models' %}
|
||||||
|
{% include 'admin/sections/models.html' %}
|
||||||
|
{% else %}
|
||||||
|
{% include 'admin/sections/overview.html' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
29
html-router/templates/admin/_layout.html
Normal file
29
html-router/templates/admin/_layout.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends 'body_base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Minne - Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div id="admin-shell" class="flex justify-center grow mt-2 sm:mt-4 pb-4">
|
||||||
|
<div class="container flex flex-col gap-4">
|
||||||
|
<section class="nb-panel p-4 sm:p-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-extrabold tracking-tight">Admin Controls</h1>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-60 sm:text-right">
|
||||||
|
Signed in as <span class="font-medium">{{ user.email }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="nb-panel p-2 flex flex-wrap gap-2 text-sm" hx-boost="true" hx-target="#admin-shell"
|
||||||
|
hx-select="#admin-shell" hx-swap="outerHTML" hx-push-url="true">
|
||||||
|
{% block admin_navigation %}
|
||||||
|
{% endblock %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="admin-content" class="flex flex-col gap-4">
|
||||||
|
{% block admin_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,38 +1 @@
|
|||||||
{% extends 'body_base.html' %}
|
{% extends "admin/_base.html" %}
|
||||||
|
|
||||||
{% block title %}Minne - Admin{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<div id="admin-shell" class="flex justify-center grow mt-2 sm:mt-4 pb-4">
|
|
||||||
<div class="container flex flex-col gap-4">
|
|
||||||
<section class="nb-panel p-4 sm:p-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-xl font-extrabold tracking-tight">Admin Controls</h1>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs opacity-60 sm:text-right">
|
|
||||||
Signed in as <span class="font-medium">{{ user.email }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<nav class="nb-panel p-2 flex flex-wrap gap-2 text-sm" hx-boost="true" hx-target="#admin-shell"
|
|
||||||
hx-select="#admin-shell" hx-swap="outerHTML" hx-push-url="true">
|
|
||||||
<a href="/admin?section=overview"
|
|
||||||
class="nb-btn btn-sm px-4 {% if current_section == 'overview' %}nb-cta{% else %}btn-ghost{% endif %}">
|
|
||||||
Overview
|
|
||||||
</a>
|
|
||||||
<a href="/admin?section=models"
|
|
||||||
class="nb-btn btn-sm px-4 {% if current_section == 'models' %}nb-cta{% else %}btn-ghost{% endif %}">
|
|
||||||
Models
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div id="admin-content" class="flex flex-col gap-4">
|
|
||||||
{% if current_section == 'models' %}
|
|
||||||
{% include 'admin/sections/models.html' %}
|
|
||||||
{% else %}
|
|
||||||
{% include 'admin/sections/overview.html' %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{% extends "modal_base.html" %}
|
{% extends "modal_base.html" %}
|
||||||
|
|
||||||
|
{% block modal_class %}max-w-3xl{% endblock %}
|
||||||
|
|
||||||
{% block form_attributes %}
|
{% block form_attributes %}
|
||||||
hx-patch="/update-image-prompt"
|
hx-patch="/update-image-prompt"
|
||||||
hx-target="#system_prompt_section"
|
hx-target="#system_prompt_section"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{% extends "modal_base.html" %}
|
{% extends "modal_base.html" %}
|
||||||
|
|
||||||
|
{% block modal_class %}max-w-3xl{% endblock %}
|
||||||
|
|
||||||
{% block form_attributes %}
|
{% block form_attributes %}
|
||||||
hx-patch="/update-ingestion-prompt"
|
hx-patch="/update-ingestion-prompt"
|
||||||
hx-target="#system_prompt_section"
|
hx-target="#system_prompt_section"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{% extends "modal_base.html" %}
|
{% extends "modal_base.html" %}
|
||||||
|
|
||||||
|
{% block modal_class %}max-w-3xl{% endblock %}
|
||||||
|
|
||||||
{% block form_attributes %}
|
{% block form_attributes %}
|
||||||
hx-patch="/update-query-prompt"
|
hx-patch="/update-query-prompt"
|
||||||
hx-target="#system_prompt_section"
|
hx-target="#system_prompt_section"
|
||||||
|
|||||||
86
html-router/templates/auth/_account_settings_core.html
Normal file
86
html-router/templates/auth/_account_settings_core.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{% extends "auth/_settings_layout.html" %}
|
||||||
|
|
||||||
|
{% block settings_header %}
|
||||||
|
<h1 class="text-xl font-extrabold tracking-tight">Account Settings</h1>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block settings_left_column %}
|
||||||
|
<label class="w-full">
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
|
||||||
|
<input type="email" name="email" value="{{ user.email }}" class="nb-input w-full" disabled />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="w-full">
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">API Key</div>
|
||||||
|
{% block api_key_section %}
|
||||||
|
{% if user.api_key %}
|
||||||
|
<div class="relative">
|
||||||
|
<input id="api_key_input" type="text" name="api_key" value="{{ user.api_key }}"
|
||||||
|
class="nb-input w-full pr-14" disabled />
|
||||||
|
<button type="button" id="copy_api_key_btn" onclick="copy_api_key()"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center px-2 nb-btn btn-sm" aria-label="Copy API key"
|
||||||
|
title="Copy API key">
|
||||||
|
{% include "icons/clipboard_icon.html" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<a href="https://www.icloud.com/shortcuts/66985f7b98a74aaeac6ba29c3f1f0960"
|
||||||
|
class="nb-btn nb-cta mt-2 w-full">Download iOS shortcut</a>
|
||||||
|
{% else %}
|
||||||
|
<button hx-post="/set-api-key" class="nb-btn nb-cta w-full" hx-swap="outerHTML">Create API-Key</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copy_api_key() {
|
||||||
|
const input = document.getElementById('api_key_input');
|
||||||
|
if (!input) return;
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(input.value)
|
||||||
|
.then(() => show_toast('API key copied!', 'success'))
|
||||||
|
.catch(() => show_toast('Copy failed', 'error'));
|
||||||
|
} else {
|
||||||
|
show_toast('Copy not supported', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="w-full">
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Timezone</div>
|
||||||
|
{% block timezone_section %}
|
||||||
|
<select name="timezone" class="nb-select w-full" hx-patch="/update-timezone" hx-swap="outerHTML">
|
||||||
|
{% for tz in timezones %}
|
||||||
|
<option value="{{ tz }}" {% if tz==user.timezone %}selected{% endif %}>{{ tz }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endblock %}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="w-full">
|
||||||
|
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Theme</div>
|
||||||
|
{% block theme_section %}
|
||||||
|
<select name="theme" class="nb-select w-full" hx-patch="/update-theme" hx-swap="outerHTML">
|
||||||
|
{% for option in theme_options %}
|
||||||
|
<option value="{{ option }}" {% if option==user.theme %}selected{% endif %}>{{ option }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<script>
|
||||||
|
document.documentElement.setAttribute('data-theme-preference', '{{ user.theme }}');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
</label>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block settings_right_column %}
|
||||||
|
<div>
|
||||||
|
{% block change_password_section %}
|
||||||
|
<button hx-get="/change-password" hx-swap="outerHTML" class="nb-btn w-full">Change Password</button>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button hx-delete="/delete-account"
|
||||||
|
hx-confirm="This action will permanently delete your account and all data associated. Are you sure you want to continue?"
|
||||||
|
class="nb-btn btn-error w-full">Delete Account</button>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
10
html-router/templates/auth/_layout.html
Normal file
10
html-router/templates/auth/_layout.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{% extends "head_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Minne - Auth{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="min-h-[100dvh] flex flex-col items-center justify-center">
|
||||||
|
{% block auth_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
32
html-router/templates/auth/_settings_layout.html
Normal file
32
html-router/templates/auth/_settings_layout.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "body_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Minne - Account{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4">
|
||||||
|
<div class="container">
|
||||||
|
<section class="mb-4">
|
||||||
|
<div class="nb-panel p-3 flex items-center justify-between">
|
||||||
|
{% block settings_header %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4 space-y-2">
|
||||||
|
<!-- Left column -->
|
||||||
|
<div class="nb-panel p-4 space-y-2 flex flex-col">
|
||||||
|
{% block settings_left_column %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column -->
|
||||||
|
<div class="nb-panel p-4 space-y-2">
|
||||||
|
{% block settings_right_column %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="account-result" class="mt-4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,88 +1 @@
|
|||||||
{% extends "body_base.html" %}
|
{% extends "auth/_account_settings_core.html" %}
|
||||||
|
|
||||||
{% block title %}Minne - Account{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4">
|
|
||||||
<div class="container">
|
|
||||||
<section class="mb-4">
|
|
||||||
<div class="nb-panel p-3 flex items-center justify-between">
|
|
||||||
<h1 class="text-xl font-extrabold tracking-tight">Account Settings</h1>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4 space-y-2">
|
|
||||||
<!-- Left column -->
|
|
||||||
<div class="nb-panel p-4 space-y-2 flex flex-col">
|
|
||||||
<label class="w-full">
|
|
||||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
|
|
||||||
<input type="email" name="email" value="{{ user.email }}" class="nb-input w-full" disabled />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="w-full">
|
|
||||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">API Key</div>
|
|
||||||
{% block api_key_section %}
|
|
||||||
{% if user.api_key %}
|
|
||||||
<div class="relative">
|
|
||||||
<input id="api_key_input" type="text" name="api_key" value="{{ user.api_key }}"
|
|
||||||
class="nb-input w-full pr-14" disabled />
|
|
||||||
<button type="button" id="copy_api_key_btn" onclick="copy_api_key()"
|
|
||||||
class="absolute inset-y-0 right-0 flex items-center px-2 nb-btn btn-sm" aria-label="Copy API key"
|
|
||||||
title="Copy API key">
|
|
||||||
{% include "icons/clipboard_icon.html" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<a href="https://www.icloud.com/shortcuts/66985f7b98a74aaeac6ba29c3f1f0960"
|
|
||||||
class="nb-btn nb-cta mt-2 w-full">Download iOS shortcut</a>
|
|
||||||
{% else %}
|
|
||||||
<button hx-post="/set-api-key" class="nb-btn nb-cta w-full" hx-swap="outerHTML">Create API-Key</button>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function copy_api_key() {
|
|
||||||
const input = document.getElementById('api_key_input');
|
|
||||||
if (!input) return;
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
|
||||||
navigator.clipboard.writeText(input.value)
|
|
||||||
.then(() => show_toast('API key copied!', 'success'))
|
|
||||||
.catch(() => show_toast('Copy failed', 'error'));
|
|
||||||
} else {
|
|
||||||
show_toast('Copy not supported', 'info');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<label class="w-full">
|
|
||||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Timezone</div>
|
|
||||||
{% block timezone_section %}
|
|
||||||
<select name="timezone" class="nb-select w-full" hx-patch="/update-timezone" hx-swap="outerHTML">
|
|
||||||
{% for tz in timezones %}
|
|
||||||
<option value="{{ tz }}" {% if tz==user.timezone %}selected{% endif %}>{{ tz }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% endblock %}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right column -->
|
|
||||||
<div class="nb-panel p-4 space-y-2">
|
|
||||||
<div>
|
|
||||||
{% block change_password_section %}
|
|
||||||
<button hx-get="/change-password" hx-swap="outerHTML" class="nb-btn w-full">Change Password</button>
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button hx-delete="/delete-account"
|
|
||||||
hx-confirm="This action will permanently delete your account and all data associated. Are you sure you want to continue?"
|
|
||||||
class="nb-btn btn-error w-full">Delete Account</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div id="account-result" class="mt-4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
{% extends "head_base.html" %}
|
{% extends "auth/_layout.html" %}
|
||||||
|
|
||||||
{% block title %}Minne - Sign in{% endblock %}
|
{% block title %}Minne - Sign in{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block auth_content %}
|
||||||
<div class="min-h-[100dvh] flex">
|
|
||||||
{% include "auth/signin_form.html" %}
|
{% include "auth/signin_form.html" %}
|
||||||
</div>
|
{% endblock %}
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
{% extends "head_base.html" %}
|
{% extends "auth/_layout.html" %}
|
||||||
|
|
||||||
{% block title %}Minne - Sign up{% endblock %}
|
{% block title %}Minne - Sign up{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block auth_content %}
|
||||||
<div class="min-h-[100dvh] flex items-center">
|
|
||||||
<div class="container mx-auto px-4 sm:max-w-md">
|
<div class="container mx-auto px-4 sm:max-w-md">
|
||||||
<div class="nb-card p-5">
|
<div class="nb-card p-5">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
@@ -46,10 +45,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<script>
|
||||||
<script>
|
// Detect timezone and set hidden input
|
||||||
// Detect timezone and set hidden input
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
document.getElementById("timezone").value = timezone;
|
||||||
document.getElementById("timezone").value = timezone;
|
</script>
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
|
||||||
<body class="relative" hx-ext="head-support">
|
<body class="relative">
|
||||||
<div class="drawer lg:drawer-open">
|
<div id="main-content-wrapper" class="drawer lg:drawer-open">
|
||||||
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<div class="drawer-content flex flex-col h-screen">
|
<div class="drawer-content flex flex-col h-screen">
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
{% block main %}{% endblock %}
|
{% block main %}{% endblock %}
|
||||||
<div class="p32 min-h-[10px]"></div>
|
<div class="p32 min-h-[10px]"></div>
|
||||||
</main>
|
</main>
|
||||||
|
{% block overlay %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
{% if user %}
|
{% if user %}
|
||||||
|
|||||||
78
html-router/templates/chat/_layout.html
Normal file
78
html-router/templates/chat/_layout.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends 'body_base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Minne - Chat{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="flex grow relative justify-center mt-2 sm:mt-4">
|
||||||
|
<div class="container">
|
||||||
|
<section class="mb-3">
|
||||||
|
<div class="nb-panel p-3 flex items-center justify-between">
|
||||||
|
{% block chat_header_actions %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div id="chat-scroll-container" class="overflow-auto hide-scrollbar">
|
||||||
|
{% block chat_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function doScrollChatToBottom() {
|
||||||
|
const mainScroll = document.querySelector('main');
|
||||||
|
if (mainScroll) mainScroll.scrollTop = mainScroll.scrollHeight;
|
||||||
|
|
||||||
|
const chatScroll = document.getElementById('chat-scroll-container');
|
||||||
|
if (chatScroll) chatScroll.scrollTop = chatScroll.scrollHeight;
|
||||||
|
|
||||||
|
const chatContainer = document.getElementById('chat_container');
|
||||||
|
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
|
||||||
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollChatToBottom() {
|
||||||
|
if (!window.location.pathname.startsWith('/chat')) return;
|
||||||
|
requestAnimationFrame(doScrollChatToBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.scrollChatToBottom = scrollChatToBottom;
|
||||||
|
|
||||||
|
// Delay initial scroll to avoid interfering with view transition
|
||||||
|
document.addEventListener('DOMContentLoaded', () => setTimeout(scrollChatToBottom, 350));
|
||||||
|
|
||||||
|
function handleChatSwap(e) {
|
||||||
|
if (!window.location.pathname.startsWith('/chat')) return;
|
||||||
|
// Full page swap: delay for view transition; partial swap: immediate
|
||||||
|
if (e.detail && e.detail.target && e.detail.target.tagName === 'BODY') {
|
||||||
|
setTimeout(scrollChatToBottom, 350);
|
||||||
|
} else {
|
||||||
|
scrollChatToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupChatListeners(e) {
|
||||||
|
if (e.detail && e.detail.target && e.detail.target.tagName === 'BODY') {
|
||||||
|
document.body.removeEventListener('htmx:afterSwap', window._chatEventHandlers.afterSwap);
|
||||||
|
document.body.removeEventListener('htmx:afterSettle', window._chatEventHandlers.afterSettle);
|
||||||
|
document.body.removeEventListener('htmx:beforeSwap', window._chatEventHandlers.beforeSwap);
|
||||||
|
delete window._chatEventHandlers;
|
||||||
|
window._chatListenersAttached = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window._chatEventHandlers = {
|
||||||
|
afterSwap: handleChatSwap,
|
||||||
|
afterSettle: handleChatSwap,
|
||||||
|
beforeSwap: cleanupChatListeners
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!window._chatListenersAttached) {
|
||||||
|
document.body.addEventListener('htmx:afterSwap', window._chatEventHandlers.afterSwap);
|
||||||
|
document.body.addEventListener('htmx:afterSettle', window._chatEventHandlers.afterSettle);
|
||||||
|
document.body.addEventListener('htmx:beforeSwap', window._chatEventHandlers.beforeSwap);
|
||||||
|
window._chatListenersAttached = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,48 +1,14 @@
|
|||||||
{% extends 'body_base.html' %}
|
{% extends "chat/_layout.html" %}
|
||||||
|
|
||||||
{% block title %}Minne - Chat{% endblock %}
|
{% block chat_header_actions %}
|
||||||
|
<h1 class="text-xl font-extrabold tracking-tight">Chat</h1>
|
||||||
{% block head %}
|
<div class="text-xs opacity-70">Converse with your knowledge</div>
|
||||||
<script src="/assets/htmx-ext-sse.js" defer></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block chat_content %}
|
||||||
<div class="flex grow relative justify-center mt-2 sm:mt-4">
|
{% include "chat/history.html" %}
|
||||||
<div class="container">
|
{% endblock %}
|
||||||
<section class="mb-3">
|
|
||||||
<div class="nb-panel p-3 flex items-center justify-between">
|
{% block overlay %}
|
||||||
<h1 class="text-xl font-extrabold tracking-tight">Chat</h1>
|
{% include "chat/new_message_form.html" %}
|
||||||
<div class="text-xs opacity-70">Converse with your knowledge</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<div id="chat-scroll-container" class="overflow-auto hide-scrollbar">
|
|
||||||
{% include "chat/history.html" %}
|
|
||||||
{% include "chat/new_message_form.html" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function scrollChatToBottom() {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const mainScroll = document.querySelector('main');
|
|
||||||
if (mainScroll) mainScroll.scrollTop = mainScroll.scrollHeight;
|
|
||||||
|
|
||||||
const chatScroll = document.getElementById('chat-scroll-container');
|
|
||||||
if (chatScroll) chatScroll.scrollTop = chatScroll.scrollHeight;
|
|
||||||
|
|
||||||
const chatContainer = document.getElementById('chat_container');
|
|
||||||
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
|
|
||||||
|
|
||||||
window.scrollTo(0, document.body.scrollHeight);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.scrollChatToBottom = scrollChatToBottom;
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', scrollChatToBottom);
|
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', scrollChatToBottom);
|
|
||||||
document.body.addEventListener('htmx:afterSettle', scrollChatToBottom);
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="fixed bottom-0 left-0 right-0 lg:left-72 z-20">
|
<div class="fixed bottom-0 left-0 right-0 lg:left-72 z-20">
|
||||||
<div class="mx-auto max-w-3xl px-4 pb-3">
|
<div class="mx-auto max-w-3xl px-4 pb-3">
|
||||||
<div class="nb-panel p-2">
|
<div class="nb-panel p-2 no-animation">
|
||||||
<form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
|
<form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
|
||||||
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2 items-end" id="chat-form">
|
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2 items-end" id="chat-form">
|
||||||
<textarea autofocus required name="content" placeholder="Type your message…" rows="3"
|
<textarea autofocus required name="content" placeholder="Type your message…" rows="3"
|
||||||
|
|||||||
15
html-router/templates/components/_icon_macro.html
Normal file
15
html-router/templates/components/_icon_macro.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% macro icon(name) %}
|
||||||
|
{% if name == "home" %}
|
||||||
|
{% include "icons/home_icon.html" %}
|
||||||
|
{% elif name == "book" %}
|
||||||
|
{% include "icons/book_icon.html" %}
|
||||||
|
{% elif name == "document" %}
|
||||||
|
{% include "icons/document_icon.html" %}
|
||||||
|
{% elif name == "chat" %}
|
||||||
|
{% include "icons/chat_icon.html" %}
|
||||||
|
{% elif name == "search" %}
|
||||||
|
{% include "icons/search_icon.html" %}
|
||||||
|
{% elif name == "scratchpad" %}
|
||||||
|
{% include "icons/scratchpad_icon.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
14
html-router/templates/components/_navbar_layout.html
Normal file
14
html-router/templates/components/_navbar_layout.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<nav class="sticky top-0 z-10 nb-panel nb-panel-canvas border-t-0 border-l-0">
|
||||||
|
<div class="container mx-auto navbar">
|
||||||
|
<div class="mr-2 flex-1">
|
||||||
|
{% block navbar_search %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div class="flex-none">
|
||||||
|
<ul class="menu menu-horizontal px-2 gap-2 items-center">
|
||||||
|
{% block navbar_actions %}
|
||||||
|
{% endblock %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
60
html-router/templates/components/_sidebar_layout.html
Normal file
60
html-router/templates/components/_sidebar_layout.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<div class="drawer-side z-20">
|
||||||
|
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||||
|
|
||||||
|
<ul class="menu p-0 w-72 h-full nb-canvas text-base-content flex flex-col border-r-2 border-neutral">
|
||||||
|
<!-- === TOP FIXED SECTION === -->
|
||||||
|
<div class="px-2 mt-4 space-y-3">
|
||||||
|
{% block sidebar_nav_items %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === MIDDLE SCROLLABLE SECTION === -->
|
||||||
|
<span class="px-4 py-2 nb-label">Recent Chats</span>
|
||||||
|
<div class="flex-1 overflow-y-auto space-y-1 custom-scrollbar">
|
||||||
|
{% if conversation_archive is defined and conversation_archive %}
|
||||||
|
{% for conversation in conversation_archive %}
|
||||||
|
<li id="conversation-{{ conversation.id }}">
|
||||||
|
{% if edit_conversation_id == conversation.id %}
|
||||||
|
<!-- Edit mode -->
|
||||||
|
<form hx-patch="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
|
||||||
|
class="flex items-center gap-1 px-2 py-2 max-w-72 relative">
|
||||||
|
<input type="text" name="title" value="{{ conversation.title }}" class="nb-input nb-input-sm max-w-52" />
|
||||||
|
<div class="flex gap-0.5 absolute right-2">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs !p-0">{% include "icons/check_icon.html" %}</button>
|
||||||
|
<button type="button" hx-get="/chat/sidebar" hx-target=".drawer-side" hx-swap="outerHTML"
|
||||||
|
class="btn btn-ghost btn-xs !p-0">
|
||||||
|
{% include "icons/x_icon.html" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<!-- View mode -->
|
||||||
|
<div class="flex w-full pl-4 pr-2 py-2">
|
||||||
|
<a hx-boost="true" href="/chat/{{ conversation.id }}" class="flex-grow text-sm truncate">
|
||||||
|
<span>{{ conversation.title }}</span>
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-0.5 ml-2">
|
||||||
|
<button hx-get="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
|
||||||
|
class="btn btn-ghost btn-xs">
|
||||||
|
{% include "icons/edit_icon.html" %}
|
||||||
|
</button>
|
||||||
|
<button hx-delete="/chat/{{ conversation.id }}" hx-target=".drawer-side" hx-swap="outerHTML"
|
||||||
|
hx-confirm="Are you sure you want to delete this chat?" class="btn btn-ghost btn-xs">
|
||||||
|
{% include "icons/delete_icon.html" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === BOTTOM FIXED SECTION === -->
|
||||||
|
<div class="px-2 pb-4 space-y-3">
|
||||||
|
{% block sidebar_bottom_actions %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
17
html-router/templates/content/_layout.html
Normal file
17
html-router/templates/content/_layout.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends 'body_base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Minne - Content{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<main id="main_section" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10 w-full">
|
||||||
|
<div class="container">
|
||||||
|
<div class="nb-panel p-3 mb-4 flex items-center justify-between">
|
||||||
|
{% block content_header %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block content_list %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,29 +1,23 @@
|
|||||||
{% extends 'body_base.html' %}
|
{% extends 'content/_layout.html' %}
|
||||||
|
|
||||||
{% block title %}Minne - Content{% endblock %}
|
{% block content_header %}
|
||||||
|
<h2 class="text-xl font-extrabold tracking-tight">Content</h2>
|
||||||
{% block main %}
|
<form hx-get="/content" hx-target="#main_section" hx-swap="outerHTML" hx-push-url="true"
|
||||||
<main id="main_section" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10 w-full">
|
class="flex items-center gap-2 mt-2 sm:mt-0">
|
||||||
<div class="container">
|
<input type="hidden" name="page" value="1" />
|
||||||
<div class="nb-panel p-3 mb-4 flex items-center justify-between">
|
<div>
|
||||||
<h2 class="text-xl font-extrabold tracking-tight">Content</h2>
|
<select name="category" class="nb-select">
|
||||||
<form hx-get="/content" hx-target="#main_section" hx-swap="outerHTML" hx-push-url="true"
|
<option value="">All Categories</option>
|
||||||
class="flex items-center gap-2 mt-2 sm:mt-0">
|
{% for category in categories %}
|
||||||
<input type="hidden" name="page" value="1" />
|
<option value="{{ category }}" {% if selected_category==category %}selected{% endif %}>{{ category }}
|
||||||
<div>
|
</option>
|
||||||
<select name="category" class="nb-select">
|
{% endfor %}
|
||||||
<option value="">All Categories</option>
|
</select>
|
||||||
{% for category in categories %}
|
|
||||||
<option value="{{ category }}" {% if selected_category==category %}selected{% endif %}>{{ category }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="nb-btn btn-sm">Filter</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="submit" class="nb-btn btn-sm">Filter</button>
|
||||||
{% include "content/content_list.html" %}
|
</form>
|
||||||
</div>
|
{% endblock %}
|
||||||
</main>
|
|
||||||
|
{% block content_list %}
|
||||||
|
{% include "content/content_list.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,15 +8,15 @@
|
|||||||
{% if text_contents|length > 0 %}
|
{% if text_contents|length > 0 %}
|
||||||
<div class="nb-masonry w-full">
|
<div class="nb-masonry w-full">
|
||||||
{% for text_content in text_contents %}
|
{% for text_content in text_contents %}
|
||||||
<article class="nb-card cursor-pointer mx-auto mb-4 w-full max-w-[92vw] space-y-3 sm:max-w-none"
|
<article class="nb-card cursor-pointer mx-auto mb-4 w-full space-y-3"
|
||||||
hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML">
|
hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML">
|
||||||
{% if text_content.url_info %}
|
{% if text_content.url_info %}
|
||||||
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
|
<figure class="nb-evidence-frame -mx-4 -mt-4 mb-3">
|
||||||
<img class="w-full h-auto" src="/file/{{text_content.url_info.image_id}}" alt="website screenshot" />
|
<img class="w-full h-auto" src="/file/{{text_content.url_info.image_id}}" alt="website screenshot" />
|
||||||
</figure>
|
</figure>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %}
|
{% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %}
|
||||||
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
|
<figure class="nb-evidence-frame -mx-4 -mt-4 mb-3">
|
||||||
<img class="w-full h-auto" src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" />
|
<img class="w-full h-auto" src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" />
|
||||||
</figure>
|
</figure>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -31,10 +31,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p class="text-xs opacity-60 shrink-0">
|
<p class="nb-data text-xs opacity-60 shrink-0">
|
||||||
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
|
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
|
||||||
</p>
|
</p>
|
||||||
<span class="nb-badge">{{ text_content.category }}</span>
|
<span class="nb-badge nb-data">{{ text_content.category }}</span>
|
||||||
<div class="flex gap-2" hx-on:click="event.stopPropagation()">
|
<div class="flex gap-2" hx-on:click="event.stopPropagation()">
|
||||||
{% if text_content.url_info %}
|
{% if text_content.url_info %}
|
||||||
<a href="{{text_content.url_info.url}}" target="_blank" rel="noopener noreferrer"
|
<a href="{{text_content.url_info.url}}" target="_blank" rel="noopener noreferrer"
|
||||||
|
|||||||
15
html-router/templates/dashboard/_base.html
Normal file
15
html-router/templates/dashboard/_base.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "dashboard/_layout.html" %}
|
||||||
|
|
||||||
|
{% block dashboard_header %}
|
||||||
|
<h1 class="text-xl font-extrabold tracking-tight">Dashboard</h1>
|
||||||
|
<button class="nb-btn nb-cta" hx-get="/ingress-form" hx-target="#modal" hx-swap="innerHTML">
|
||||||
|
{% include "icons/send_icon.html" %}
|
||||||
|
<span class="ml-2">Add Content</span>
|
||||||
|
</button>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block dashboard_widgets %}
|
||||||
|
{% include "dashboard/statistics.html" %}
|
||||||
|
{% include "dashboard/recent_content.html" %}
|
||||||
|
{% include "dashboard/active_jobs.html" %}
|
||||||
|
{% endblock %}
|
||||||
21
html-router/templates/dashboard/_layout.html
Normal file
21
html-router/templates/dashboard/_layout.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "body_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Minne - Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4 w-full">
|
||||||
|
<div class="container">
|
||||||
|
<section class="mb-4">
|
||||||
|
{% block dashboard_alerts %}
|
||||||
|
{% endblock %}
|
||||||
|
<div class="nb-panel p-3 flex items-center justify-between">
|
||||||
|
{% block dashboard_header %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% block dashboard_widgets %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,30 +1 @@
|
|||||||
{% extends "body_base.html" %}
|
{% extends "dashboard/_base.html" %}
|
||||||
|
|
||||||
{% block title %}Minne - Dashboard{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<script src="/assets/htmx-ext-sse.js" defer></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4 w-full">
|
|
||||||
<div class="container">
|
|
||||||
<section class="mb-4">
|
|
||||||
<div class="nb-panel p-3 flex items-center justify-between">
|
|
||||||
<h1 class="text-xl font-extrabold tracking-tight">Dashboard</h1>
|
|
||||||
<button class="nb-btn nb-cta" hx-get="/ingress-form" hx-target="#modal" hx-swap="innerHTML">
|
|
||||||
{% include "icons/send_icon.html" %}
|
|
||||||
<span class="ml-2">Add Content</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% include "dashboard/statistics.html" %}
|
|
||||||
|
|
||||||
{% include "dashboard/recent_content.html" %}
|
|
||||||
|
|
||||||
{% include "dashboard/active_jobs.html" %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
9
html-router/templates/errors/_layout.html
Normal file
9
html-router/templates/errors/_layout.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends 'body_base.html' %}
|
||||||
|
{% block main %}
|
||||||
|
<main class="container justify-center flex-grow flex mx-auto mt-4">
|
||||||
|
<div class="flex flex-col space-y-4 text-center justify-center">
|
||||||
|
{% block error_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
{% extends 'body_base.html' %}
|
{% extends "errors/_layout.html" %}
|
||||||
{% block main %}
|
|
||||||
<main class="container justify-center flex-grow flex mx-auto mt-4">
|
{% block error_content %}
|
||||||
<div class="flex flex-col space-y-4 text-center justify-center">
|
<h1 class="text-2xl font-bold text-error">
|
||||||
<h1 class="text-2xl font-bold text-error">
|
{{ status_code }}
|
||||||
{{ status_code }}
|
</h1>
|
||||||
</h1>
|
<p class="text-2xl my-4">{{ title }}</p>
|
||||||
<p class="text-2xl my-4">{{ title }}</p>
|
<p class="text-base-content/60">{{ description }}</p>
|
||||||
<p class="text-base-content/60">{{ description }}</p>
|
<a href="/" class="btn btn-primary mt-8">Go Home</a>
|
||||||
<a href="/" class="btn btn-primary mt-8">Go Home</a>
|
{% endblock %}
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="light">
|
<html lang="en" data-theme="{{ initial_theme|default('light') }}" data-theme-preference="{{ user_theme|default('system') }}">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@@ -16,11 +16,15 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/assets/htmx.min.js" defer></script>
|
<script src="/assets/htmx.min.js" defer></script>
|
||||||
|
<script src="/assets/htmx-ext-sse.js" defer></script>
|
||||||
<script src="/assets/theme-toggle.js" defer></script>
|
<script src="/assets/theme-toggle.js" defer></script>
|
||||||
<script src="/assets/toast.js" defer></script>
|
<script src="/assets/toast.js" defer></script>
|
||||||
<script src="/assets/htmx-head-ext.js" defer></script>
|
|
||||||
<script src="/assets/marked.min.js" defer></script>
|
<script src="/assets/marked.min.js" defer></script>
|
||||||
<script src="/assets/knowledge-graph.js" defer></script>
|
<script src="/assets/knowledge-graph.js" defer></script>
|
||||||
|
<script src="/assets/design-polish.js" defer></script>
|
||||||
|
|
||||||
|
<!-- Global View Transition -->
|
||||||
|
<meta name="view-transition" content="same-origin" />
|
||||||
|
|
||||||
<!-- Icons -->
|
<!-- Icons -->
|
||||||
<link rel="icon" href="/assets/icon/favicon.ico">
|
<link rel="icon" href="/assets/icon/favicon.ico">
|
||||||
@@ -38,6 +42,7 @@
|
|||||||
(function wait_for_htmx() {
|
(function wait_for_htmx() {
|
||||||
if (window.htmx) {
|
if (window.htmx) {
|
||||||
htmx.config.globalViewTransitions = true;
|
htmx.config.globalViewTransitions = true;
|
||||||
|
htmx.config.selfRequestsOnly = false;
|
||||||
} else {
|
} else {
|
||||||
setTimeout(wait_for_htmx, 50);
|
setTimeout(wait_for_htmx, 50);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,78 @@
|
|||||||
{% extends "modal_base.html" %}
|
{% extends "modal_base.html" %}
|
||||||
|
|
||||||
|
{% block modal_class %}max-w-3xl{% endblock %}
|
||||||
|
|
||||||
{% block form_attributes %}
|
{% block form_attributes %}
|
||||||
hx-post="/ingress-form"
|
hx-post="/ingress-form"
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block modal_content %}
|
{% block modal_content %}
|
||||||
<h3 class="text-xl font-extrabold tracking-tight">Add New Content</h3>
|
<h3 class="text-xl font-extrabold tracking-tight pr-8">Add New Content</h3>
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<!-- Content Source -->
|
||||||
<label class="w-full">
|
<label class="w-full">
|
||||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Content</div>
|
<div class="nb-label mb-1">Content</div>
|
||||||
<textarea name="content" class="nb-input w-full min-h-28"
|
<textarea name="content" class="nb-input w-full" rows="4" autofocus
|
||||||
placeholder="Paste a URL or type/paste text to ingest…">{{ content }}</textarea>
|
placeholder="Paste a URL or type/paste text to ingest…">{{ content }}</textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="w-full">
|
<!-- Context (Optional) -->
|
||||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Context</div>
|
<label class="w-full mt-6">
|
||||||
<textarea name="context" class="nb-input w-full min-h-24"
|
<div class="nb-label mb-1 flex justify-between items-center">
|
||||||
placeholder="Optional: add context to guide how the content should be interpreted…">{{ context }}</textarea>
|
<span>Context</span>
|
||||||
|
<!-- Tufte-style annotation: clean, small caps, structural -->
|
||||||
|
<span class="text-[10px] tracking-widest uppercase border border-neutral px-1.5 py-px bg-transparent opacity-60">Optional</span>
|
||||||
|
</div>
|
||||||
|
<textarea name="context" class="nb-input w-full" rows="2"
|
||||||
|
placeholder="Guide how this content should be interpreted…">{{ context }}</textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="w-full">
|
<!-- Metadata Grid -->
|
||||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Category</div>
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-12 gap-y-8 items-start mt-6">
|
||||||
<input type="text" name="category" class="nb-input validator w-full" value="{{ category }}" list="category-list" required />
|
|
||||||
<datalist id="category-list">
|
<!-- Category -->
|
||||||
{% for category in user_categories %}
|
<label class="w-full">
|
||||||
<option value="{{ category }}" />
|
<div class="nb-label mb-1">Category <span class="text-error font-bold" title="Required">*</span></div>
|
||||||
{% endfor %}
|
<div class="relative">
|
||||||
</datalist>
|
<input type="text" name="category" class="nb-input validator w-full pr-8" value="{{ category }}" list="category-list" required placeholder="Select or type..." />
|
||||||
<div class="validator-hint hidden text-xs opacity-70 mt-1">Category is required</div>
|
<div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none opacity-50">
|
||||||
</label>
|
{% include "icons/chevron_icon.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<datalist id="category-list">
|
||||||
|
{% for category in user_categories %}
|
||||||
|
<option value="{{ category }}" />
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
|
<div class="validator-hint hidden text-xs opacity-70 mt-1 text-error">Category is required</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label class="w-full">
|
<!-- Dimensional File Drop Zone -->
|
||||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Files</div>
|
<div class="w-full">
|
||||||
<input type="file" name="files" multiple class="file-input w-full rounded-none border-2 border-neutral" />
|
<div class="nb-label mb-1">Files</div>
|
||||||
</label>
|
<!-- "Card" style dropzone: solid border, hard shadow, lift on hover -->
|
||||||
|
<div class="relative w-full h-32 group bg-base-100 border-2 border-neutral shadow-[4px_4px_0_0_#000] hover:translate-x-[-1px] hover:translate-y-[-1px] hover:shadow-[6px_6px_0_0_#000] transition-all duration-150">
|
||||||
|
<!-- Visual Facade -->
|
||||||
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-sm font-medium text-neutral pointer-events-none">
|
||||||
|
<div class="p-2 border-2 border-neutral rounded-none bg-base-200 group-hover:bg-base-100 transition-colors">
|
||||||
|
<span class="w-6 h-6 block">{% include "icons/document_icon.html" %}</span>
|
||||||
|
</div>
|
||||||
|
<span id="file-label-text" class="text-center px-4 text-xs uppercase tracking-wide">Drop files or click</span>
|
||||||
|
</div>
|
||||||
|
<!-- Actual Input -->
|
||||||
|
<input type="file" name="files" multiple
|
||||||
|
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
onchange="const count = this.files.length; document.getElementById('file-label-text').innerText = count > 0 ? count + ' FILE' + (count !== 1 ? 'S' : '') + ' SELECTED' : 'DROP FILES OR CLICK';" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="error-message" class="text-error text-center {% if not error %}hidden{% endif %}">{{ error }}</div>
|
<div id="error-message" class="text-error text-center {% if not error %}hidden{% endif %}">{{ error }}</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const form = document.getElementById('modal_form');
|
const form = document.getElementById('modal_form');
|
||||||
@@ -51,8 +88,9 @@ enctype="multipart/form-data"
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block primary_actions %}
|
{% block primary_actions %}
|
||||||
<button type="submit" class="nb-btn nb-cta">
|
<button type="submit" class="nb-btn nb-cta w-full sm:w-auto">
|
||||||
Add Content
|
Add Content
|
||||||
</button>
|
</button>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
17
html-router/templates/knowledge/_layout.html
Normal file
17
html-router/templates/knowledge/_layout.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends 'body_base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Minne - Knowledge{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6">
|
||||||
|
<div class="container">
|
||||||
|
<div class="nb-panel p-3 mb-4 space-y-3 sm:space-y-0 sm:flex sm:flex-row sm:justify-between sm:items-center">
|
||||||
|
{% block knowledge_header %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block knowledge_content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,52 +1,46 @@
|
|||||||
{% extends 'body_base.html' %}
|
{% extends 'knowledge/_layout.html' %}
|
||||||
|
|
||||||
{% block title %}Minne - Knowledge{% endblock %}
|
{% block knowledge_header %}
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
{% block main %}
|
<h2 class="text-xl font-extrabold tracking-tight">Knowledge Entities</h2>
|
||||||
<div id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6">
|
<button type="button" class="nb-btn nb-cta btn-sm mr-2" hx-get="/knowledge-entity/new" hx-target="#modal"
|
||||||
<div class="container">
|
hx-swap="innerHTML">
|
||||||
<div class="nb-panel p-3 mb-4 space-y-3 sm:space-y-0 sm:flex sm:flex-row sm:justify-between sm:items-center">
|
New Entity
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
</button>
|
||||||
<h2 class="text-xl font-extrabold tracking-tight">Knowledge Entities</h2>
|
|
||||||
<button type="button" class="nb-btn nb-cta btn-sm mr-2" hx-get="/knowledge-entity/new" hx-target="#modal"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
New Entity
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form hx-get="/knowledge" hx-target="#knowledge_pane" hx-push-url="true" hx-swap="outerHTML"
|
|
||||||
class="flex items-center gap-2 mt-2 sm:mt-0">
|
|
||||||
<input type="hidden" name="page" value="1" />
|
|
||||||
<div>
|
|
||||||
<select name="entity_type" class="nb-select">
|
|
||||||
<option value="">All Types</option>
|
|
||||||
{% for type in entity_types %}
|
|
||||||
<option value="{{ type }}" {% if selected_entity_type==type %}selected{% endif %}>{{ type }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<select name="content_category" class="nb-select">
|
|
||||||
<option value="">All Categories</option>
|
|
||||||
{% for category in content_categories %}
|
|
||||||
<option value="{{ category }}" {% if selected_content_category==category %}selected{% endif %}>{{ category
|
|
||||||
}}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="nb-btn btn-sm">Filter</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="text-2xl font-bold mb-2 mt-10 ">Graph</h2>
|
|
||||||
<div class="nb-card mt-4 p-2">
|
|
||||||
<div id="knowledge-graph" class="w-full" style="height: 640px;"
|
|
||||||
data-entity-type="{{ selected_entity_type | default(value='') }}"
|
|
||||||
data-content-category="{{ selected_content_category | default(value='') }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% include "knowledge/entity_list.html" %}
|
|
||||||
<h2 class="text-2xl font-bold mb-2 mt-2">Relationships</h2>
|
|
||||||
{% include "knowledge/relationship_table.html" %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<form hx-get="/knowledge" hx-target="#knowledge_pane" hx-push-url="true" hx-swap="outerHTML"
|
||||||
{% endblock %}
|
class="flex items-center gap-2 mt-2 sm:mt-0">
|
||||||
|
<input type="hidden" name="page" value="1" />
|
||||||
|
<div>
|
||||||
|
<select name="entity_type" class="nb-select">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
{% for type in entity_types %}
|
||||||
|
<option value="{{ type }}" {% if selected_entity_type==type %}selected{% endif %}>{{ type }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select name="content_category" class="nb-select">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{% for category in content_categories %}
|
||||||
|
<option value="{{ category }}" {% if selected_content_category==category %}selected{% endif %}>{{ category
|
||||||
|
}}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="nb-btn btn-sm">Filter</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block knowledge_content %}
|
||||||
|
<h2 class="text-2xl font-bold mb-2 mt-10 ">Graph</h2>
|
||||||
|
<div class="nb-card mt-4 p-2">
|
||||||
|
<div id="knowledge-graph" class="w-full" style="height: 640px;"
|
||||||
|
data-entity-type="{{ selected_entity_type | default(value='') }}"
|
||||||
|
data-content-category="{{ selected_content_category | default(value='') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include "knowledge/entity_list.html" %}
|
||||||
|
<h2 class="text-2xl font-bold mb-2 mt-2">Relationships</h2>
|
||||||
|
{% include "knowledge/relationship_table.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
<dialog id="body_modal" class="modal">
|
<dialog id="body_modal" class="modal">
|
||||||
<div
|
<div
|
||||||
class="modal-box rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] {% block modal_class %}{% endblock %}">
|
class="modal-box relative rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] p-6 {% block modal_class %}max-w-lg{% endblock %}">
|
||||||
|
|
||||||
|
<!-- God Level UX: Explicit Escape Hatch -->
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-square btn-ghost absolute right-2 top-2 z-10"
|
||||||
|
onclick="document.getElementById('body_modal').close()"
|
||||||
|
aria-label="Close modal">
|
||||||
|
{% include "icons/x_icon.html" %}
|
||||||
|
</button>
|
||||||
|
|
||||||
<form id="modal_form" {% block form_attributes %}{% endblock %}>
|
<form id="modal_form" {% block form_attributes %}{% endblock %}>
|
||||||
<div class="flex flex-col flex-1 gap-4">
|
<div class="flex flex-col flex-1 gap-5">
|
||||||
{% block modal_content %}{% endblock %}
|
{% block modal_content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="u-hairline mt-4 pt-3 flex flex-col gap-2 sm:flex-row sm:justify-end sm:items-center">
|
<div class="mt-8 pt-2 flex flex-col gap-2 sm:flex-row sm:justify-end sm:items-center">
|
||||||
<!-- Close button (always visible) -->
|
<!-- Secondary Action: Ghost style to reduce noise -->
|
||||||
<button type="button" class="nb-btn w-full sm:w-auto" onclick="document.getElementById('body_modal').close()">
|
<button type="button" class="btn btn-ghost rounded-none w-full sm:w-auto hover:bg-neutral/10" onclick="document.getElementById('body_modal').close()">
|
||||||
Close
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Primary actions block -->
|
<!-- Primary actions block -->
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<nav class="sticky top-0 z-10 nb-panel nb-panel-canvas border-t-0">
|
{% extends "components/_navbar_layout.html" %}
|
||||||
<div class="container mx-auto navbar">
|
|
||||||
<div class="mr-2 flex-1">
|
{% block navbar_search %}
|
||||||
{% include "searchbar.html" %}
|
{% include "searchbar.html" %}
|
||||||
</div>
|
{% endblock %}
|
||||||
<div class="flex-none">
|
|
||||||
<ul class="menu menu-horizontal px-2 gap-2 items-center">
|
{% block navbar_actions %}
|
||||||
<label for="my-drawer" aria-label="open sidebar" class="hover:cursor-pointer lg:hidden">
|
<label for="my-drawer" aria-label="open sidebar" class="hover:cursor-pointer lg:hidden">
|
||||||
{% include "icons/hamburger_icon.html" %}
|
{% include "icons/hamburger_icon.html" %}
|
||||||
</label>
|
</label>
|
||||||
{% include "theme_toggle.html" %}
|
{% if not is_authenticated %}
|
||||||
</ul>
|
{% include "theme_toggle.html" %}
|
||||||
</div>
|
{% endif %}
|
||||||
</div>
|
{% endblock %}
|
||||||
</nav>
|
|
||||||
|
|||||||
24
html-router/templates/scratchpad/_layout.html
Normal file
24
html-router/templates/scratchpad/_layout.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends 'body_base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Minne - Scratchpad{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<main id="main_section" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10 w-full">
|
||||||
|
<div class="container">
|
||||||
|
{% block scratchpad_header %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scratchpad_content %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scratchpad_archived %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% if new_scratchpad %}
|
||||||
|
<div hx-swap-oob="innerHTML:#modal">
|
||||||
|
<div hx-get="/scratchpad/{{ new_scratchpad.id }}/modal" hx-trigger="load" hx-target="#modal" hx-swap="innerHTML"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,113 +1,101 @@
|
|||||||
{% extends 'body_base.html' %}
|
{% extends 'scratchpad/_layout.html' %}
|
||||||
|
|
||||||
{% block title %}Minne - Scratchpad{% endblock %}
|
{% block scratchpad_header %}
|
||||||
|
<div class="nb-panel p-3 mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-extrabold tracking-tight">Scratchpads</h2>
|
||||||
|
<form hx-post="/scratchpad" hx-target="#main_section" hx-swap="outerHTML" class="flex gap-2">
|
||||||
|
<input type="text" name="title" placeholder="Enter scratchpad title..." class="nb-input nb-input-sm" required>
|
||||||
|
<button type="submit" class="nb-btn nb-cta">
|
||||||
|
{% include "icons/scratchpad_icon.html" %} Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block scratchpad_content %}
|
||||||
<main id="main_section" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10 w-full">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div class="container">
|
{% for scratchpad in scratchpads %}
|
||||||
{% block header %}
|
<div class="nb-card p-4 hover:nb-shadow-hover transition-all">
|
||||||
<div class="nb-panel p-3 mb-4 flex items-center justify-between">
|
<div class="flex justify-between items-start mb-2">
|
||||||
<h2 class="text-xl font-extrabold tracking-tight">Scratchpads</h2>
|
<h3 class="font-semibold text-lg truncate flex-1">{{ scratchpad.title }}</h3>
|
||||||
<form hx-post="/scratchpad" hx-target="#main_section" hx-swap="outerHTML" class="flex gap-2">
|
<div class="flex gap-1 ml-2">
|
||||||
<input type="text" name="title" placeholder="Enter scratchpad title..." class="nb-input nb-input-sm" required>
|
<button hx-get="/scratchpad/{{ scratchpad.id }}/modal" hx-target="#modal" hx-swap="innerHTML"
|
||||||
|
class="nb-btn nb-btn-sm btn-ghost" title="Edit scratchpad">
|
||||||
|
{% include "icons/pencil_icon.html" %}
|
||||||
|
</button>
|
||||||
|
<form hx-post="/scratchpad/{{ scratchpad.id }}/archive" hx-target="#main_section" hx-swap="outerHTML"
|
||||||
|
class="inline-flex">
|
||||||
|
<button type="submit" class="nb-btn nb-btn-sm btn-ghost text-warning" title="Archive scratchpad">
|
||||||
|
{% include "icons/delete_icon.html" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-base-content/70 mb-2">
|
||||||
|
{{ scratchpad.content[:100] }}{% if scratchpad.content|length > 100 %}...{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-base-content/50">
|
||||||
|
Last saved: {{ scratchpad.last_saved_at | datetimeformat(format="short", tz=user.timezone) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-span-full nb-panel p-8 text-center">
|
||||||
|
<h3 class="text-lg font-semibold mt-2 mb-2">No scratchpads yet</h3>
|
||||||
|
<p class="text-base-content/70 mb-4">Create your first scratchpad to start jotting down ideas</p>
|
||||||
|
<form hx-post="/scratchpad" hx-target="#main_section" hx-swap="outerHTML"
|
||||||
|
class="inline-flex gap-2">
|
||||||
|
<input type="text" name="title" placeholder="My first scratchpad..." class="nb-input" required>
|
||||||
<button type="submit" class="nb-btn nb-cta">
|
<button type="submit" class="nb-btn nb-cta">
|
||||||
{% include "icons/scratchpad_icon.html" %} Create
|
{% include "icons/scratchpad_icon.html" %} Create Scratchpad
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block scratchpad_archived %}
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
{% if archived_scratchpads %}
|
||||||
{% for scratchpad in scratchpads %}
|
<div class="mt-6">
|
||||||
<div class="nb-card p-4 hover:nb-shadow-hover transition-all">
|
<details class="nb-panel p-3 space-y-4">
|
||||||
<div class="flex justify-between items-start mb-2">
|
<summary class="flex items-center justify-between gap-2 text-sm font-semibold cursor-pointer">
|
||||||
<h3 class="font-semibold text-lg truncate flex-1">{{ scratchpad.title }}</h3>
|
<span>Archived Scratchpads</span>
|
||||||
<div class="flex gap-1 ml-2">
|
<span class="nb-badge">{{ archived_scratchpads|length }}</span>
|
||||||
<button hx-get="/scratchpad/{{ scratchpad.id }}/modal" hx-target="#modal" hx-swap="innerHTML"
|
</summary>
|
||||||
class="nb-btn nb-btn-sm btn-ghost" title="Edit scratchpad">
|
|
||||||
{% include "icons/pencil_icon.html" %}
|
|
||||||
</button>
|
|
||||||
<form hx-post="/scratchpad/{{ scratchpad.id }}/archive" hx-target="#main_section" hx-swap="outerHTML"
|
|
||||||
class="inline-flex">
|
|
||||||
<button type="submit" class="nb-btn nb-btn-sm btn-ghost text-warning" title="Archive scratchpad">
|
|
||||||
{% include "icons/delete_icon.html" %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-base-content/70 mb-2">
|
|
||||||
{{ scratchpad.content[:100] }}{% if scratchpad.content|length > 100 %}...{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-base-content/50">
|
|
||||||
Last saved: {{ scratchpad.last_saved_at | datetimeformat(format="short", tz=user.timezone) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="col-span-full nb-panel p-8 text-center">
|
|
||||||
<h3 class="text-lg font-semibold mt-2 mb-2">No scratchpads yet</h3>
|
|
||||||
<p class="text-base-content/70 mb-4">Create your first scratchpad to start jotting down ideas</p>
|
|
||||||
<form hx-post="/scratchpad" hx-target="#main_section" hx-swap="outerHTML"
|
|
||||||
class="inline-flex gap-2">
|
|
||||||
<input type="text" name="title" placeholder="My first scratchpad..." class="nb-input" required>
|
|
||||||
<button type="submit" class="nb-btn nb-cta">
|
|
||||||
{% include "icons/scratchpad_icon.html" %} Create Scratchpad
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% if archived_scratchpads %}
|
<div class="text-sm text-base-content/60">Archived scratchpads were ingested into your knowledge base. You can
|
||||||
<div class="mt-6">
|
restore them if you want to keep editing.</div>
|
||||||
<details class="nb-panel p-3 space-y-4">
|
|
||||||
<summary class="flex items-center justify-between gap-2 text-sm font-semibold cursor-pointer">
|
|
||||||
<span>Archived Scratchpads</span>
|
|
||||||
<span class="nb-badge">{{ archived_scratchpads|length }}</span>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div class="text-sm text-base-content/60">Archived scratchpads were ingested into your knowledge base. You can
|
<div class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
restore them if you want to keep editing.</div>
|
{% for scratchpad in archived_scratchpads %}
|
||||||
|
<div class="nb-card p-3 space-y-3">
|
||||||
<div class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
{% for scratchpad in archived_scratchpads %}
|
<div class="flex-1 min-w-0">
|
||||||
<div class="nb-card p-3 space-y-3">
|
<h4 class="font-semibold text-base truncate" title="{{ scratchpad.title }}">{{ scratchpad.title }}</h4>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="text-xs text-base-content/50">Archived {{ scratchpad.archived_at | datetimeformat(format="short", tz=user.timezone) }}</div>
|
||||||
<div class="flex-1 min-w-0">
|
{% if scratchpad.ingested_at %}
|
||||||
<h4 class="font-semibold text-base truncate" title="{{ scratchpad.title }}">{{ scratchpad.title }}</h4>
|
<div class="text-xs text-base-content/40">Ingestion started {{ scratchpad.ingested_at | datetimeformat(format="short", tz=user.timezone) }}</div>
|
||||||
<div class="text-xs text-base-content/50">Archived {{ scratchpad.archived_at | datetimeformat(format="short", tz=user.timezone) }}</div>
|
{% endif %}
|
||||||
{% if scratchpad.ingested_at %}
|
</div>
|
||||||
<div class="text-xs text-base-content/40">Ingestion started {{ scratchpad.ingested_at | datetimeformat(format="short", tz=user.timezone) }}</div>
|
<div class="flex items-center gap-2 flex-shrink-0 flex-wrap justify-end">
|
||||||
{% endif %}
|
<form hx-post="/scratchpad/{{ scratchpad.id }}/restore" hx-target="#main_section" hx-swap="outerHTML"
|
||||||
</div>
|
class="inline-flex">
|
||||||
<div class="flex items-center gap-2 flex-shrink-0 flex-wrap justify-end">
|
<button type="submit" class="nb-btn nb-btn-sm">
|
||||||
<form hx-post="/scratchpad/{{ scratchpad.id }}/restore" hx-target="#main_section" hx-swap="outerHTML"
|
Restore
|
||||||
class="inline-flex">
|
</button>
|
||||||
<button type="submit" class="nb-btn nb-btn-sm">
|
</form>
|
||||||
Restore
|
<form hx-delete="/scratchpad/{{ scratchpad.id }}" hx-target="#main_section" hx-swap="outerHTML"
|
||||||
</button>
|
hx-confirm="Permanently delete this scratchpad?" class="inline-flex">
|
||||||
</form>
|
<button type="submit" class="nb-btn nb-btn-sm btn-ghost text-error" title="Delete permanently">
|
||||||
<form hx-delete="/scratchpad/{{ scratchpad.id }}" hx-target="#main_section" hx-swap="outerHTML"
|
{% include "icons/delete_icon.html" %}
|
||||||
hx-confirm="Permanently delete this scratchpad?" class="inline-flex">
|
</button>
|
||||||
<button type="submit" class="nb-btn nb-btn-sm btn-ghost text-error" title="Delete permanently">
|
</form>
|
||||||
{% include "icons/delete_icon.html" %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
{% endif %}
|
||||||
|
|
||||||
{% if new_scratchpad %}
|
|
||||||
<div hx-swap-oob="innerHTML:#modal">
|
|
||||||
<div hx-get="/scratchpad/{{ new_scratchpad.id }}/modal" hx-trigger="load" hx-target="#modal" hx-swap="innerHTML"></div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{% block modal_content %}
|
{% block modal_content %}
|
||||||
<h3 class="text-xl font-extrabold tracking-tight">
|
<h3 class="text-xl font-extrabold tracking-tight">
|
||||||
<div class="flex items-center gap-2" id="title-container">
|
<div class="flex items-center gap-2 {% if is_editing_title %}hidden{% endif %}" id="title-container">
|
||||||
<span class="font-semibold text-lg flex-1 truncate" id="title-display">{{ scratchpad.title }}</span>
|
<span class="font-semibold text-lg flex-1 truncate" id="title-display">{{ scratchpad.title }}</span>
|
||||||
<button type="button" onclick="editTitle()" class="nb-btn nb-btn-sm btn-ghost">
|
<button type="button" onclick="editTitle()" class="nb-btn nb-btn-sm btn-ghost">
|
||||||
{% include "icons/edit_icon.html" %} Edit title
|
{% include "icons/edit_icon.html" %} Edit title
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
|
|
||||||
<!-- Hidden title form -->
|
<!-- Hidden title form -->
|
||||||
<form id="title-form" hx-patch="/scratchpad/{{ scratchpad.id }}/title" hx-target="#body_modal" hx-swap="outerHTML"
|
<form id="title-form" hx-patch="/scratchpad/{{ scratchpad.id }}/title" hx-target="#body_modal" hx-swap="outerHTML"
|
||||||
class="hidden flex items-center gap-2">
|
class="{% if not is_editing_title %}hidden{% endif %} flex items-center gap-2">
|
||||||
<input type="text" name="title" value="{{ scratchpad.title }}"
|
<input type="text" name="title" value="{{ scratchpad.title }}"
|
||||||
class="nb-input nb-input-sm font-semibold text-lg flex-1" id="title-input">
|
class="nb-input nb-input-sm font-semibold text-lg flex-1" id="title-input" {% if is_editing_title %}autofocus{% endif %}>
|
||||||
<button type="submit" class="nb-btn nb-btn-sm">{% include "icons/check_icon.html" %}</button>
|
<button type="submit" class="nb-btn nb-btn-sm">{% include "icons/check_icon.html" %}</button>
|
||||||
<button type="button" onclick="cancelEditTitle()" class="nb-btn nb-btn-sm btn-ghost">{% include "icons/x_icon.html" %}</button>
|
<button type="button" onclick="cancelEditTitle()" class="nb-btn nb-btn-sm btn-ghost">{% include "icons/x_icon.html" %}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
|
|
||||||
<!-- Theme switch script -->
|
|
||||||
<script>
|
|
||||||
const initializeTheme = () => {
|
|
||||||
console.log("Initializing theme toggle...");
|
|
||||||
const themeToggle = document.querySelector('.theme-controller');
|
|
||||||
if (!themeToggle) {
|
|
||||||
console.log("Theme toggle not found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect system preference
|
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
|
|
||||||
// Initialize theme from local storage or system preference
|
|
||||||
const savedTheme = localStorage.getItem('theme');
|
|
||||||
const initialTheme = savedTheme ? savedTheme : (prefersDark ? 'dark' : 'light');
|
|
||||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
|
||||||
themeToggle.checked = initialTheme === 'dark';
|
|
||||||
|
|
||||||
// Update theme and local storage on toggle
|
|
||||||
themeToggle.addEventListener('change', () => {
|
|
||||||
const theme = themeToggle.checked ? 'dark' : 'light';
|
|
||||||
console.log("Theme switched to:", theme);
|
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Theme toggle initialized.");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run the initialization after the DOM is fully loaded
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
console.log("DOM fully loaded. Initializing theme toggle...");
|
|
||||||
initializeTheme();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reinitialize theme toggle after HTMX swaps
|
|
||||||
document.addEventListener('htmx:afterSwap', initializeTheme);
|
|
||||||
document.addEventListener('htmx:afterSettle', initializeTheme);
|
|
||||||
</script>
|
|
||||||
18
html-router/templates/search/_layout.html
Normal file
18
html-router/templates/search/_layout.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "body_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Minne - Search{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="flex justify-center grow mt-2 sm:mt-4">
|
||||||
|
<div class="container">
|
||||||
|
<section class="mb-4">
|
||||||
|
<div class="nb-panel p-3 flex items-center justify-between">
|
||||||
|
{% block search_header %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% block search_results %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
{% extends "body_base.html" %}
|
{% extends "search/_layout.html" %}
|
||||||
|
|
||||||
{% block title %}Minne - Search{% endblock %}
|
{% block search_header %}
|
||||||
|
<h1 class="text-xl font-extrabold tracking-tight">Search</h1>
|
||||||
{% block main %}
|
<div class="text-xs opacity-70">Find documents, entities, and snippets</div>
|
||||||
<div class="flex justify-center grow mt-2 sm:mt-4">
|
{% endblock %}
|
||||||
<div class="container">
|
|
||||||
<section class="mb-4">
|
{% block search_results %}
|
||||||
<div class="nb-panel p-3 flex items-center justify-between">
|
{% include "search/response.html" %}
|
||||||
<h1 class="text-xl font-extrabold tracking-tight">Search</h1>
|
|
||||||
<div class="text-xs opacity-70">Find documents, entities, and snippets</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% include "search/response.html" %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,119 +1,52 @@
|
|||||||
{% macro icon(name) %}
|
{% extends "components/_sidebar_layout.html" %}
|
||||||
{% if name == "home" %}
|
{% from "components/_icon_macro.html" import icon %}
|
||||||
{% include "icons/home_icon.html" %}
|
|
||||||
{% elif name == "book" %}
|
|
||||||
{% include "icons/book_icon.html" %}
|
|
||||||
{% elif name == "document" %}
|
|
||||||
{% include "icons/document_icon.html" %}
|
|
||||||
{% elif name == "chat" %}
|
|
||||||
{% include "icons/chat_icon.html" %}
|
|
||||||
{% elif name == "search" %}
|
|
||||||
{% include "icons/search_icon.html" %}
|
|
||||||
{% elif name == "scratchpad" %}
|
|
||||||
{% include "icons/scratchpad_icon.html" %}
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
<div class="drawer-side z-20">
|
{% block sidebar_nav_items %}
|
||||||
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
{% for url, name, label in [
|
||||||
|
("/", "home", "Dashboard"),
|
||||||
|
("/knowledge", "book", "Knowledge"),
|
||||||
|
("/content", "document", "Content"),
|
||||||
|
("/chat", "chat", "Chat"),
|
||||||
|
("/search", "search", "Search"),
|
||||||
|
("/scratchpad", "scratchpad", "Scratchpad")
|
||||||
|
] %}
|
||||||
|
<li>
|
||||||
|
<a hx-boost="true" href="{{ url }}" class="nb-btn w-full justify-start gap-3 bg-base-100 hover:bg-base-200">
|
||||||
|
{{ icon(name) }}
|
||||||
|
<span class="uppercase tracking-wide">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li>
|
||||||
|
<button class="nb-btn nb-cta w-full flex items-center gap-3 justify-start mt-2" hx-get="/ingress-form"
|
||||||
|
hx-target="#modal" hx-swap="innerHTML">{% include "icons/send_icon.html" %} Add
|
||||||
|
Content</button>
|
||||||
|
</li>
|
||||||
|
<div class="u-hairline mt-4"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<ul class="menu p-0 w-72 h-full nb-canvas text-base-content flex flex-col border-r-2 border-neutral">
|
{% block sidebar_bottom_actions %}
|
||||||
<!-- <a class="px-4 py-4 text-2xl font-extrabold tracking-tight text-primary border-b-2 border-neutral bg-base-100 nb-shadow" -->
|
<li>
|
||||||
<!-- href="/" hx-boost="true">Minne</a> -->
|
<a hx-boost="true" href="/account"
|
||||||
|
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200">
|
||||||
<!-- === TOP FIXED SECTION === -->
|
{% include "icons/user_icon.html" %}
|
||||||
<div class="px-2 mt-4 space-y-3">
|
<span class="uppercase tracking-wide">Account</span>
|
||||||
{% for url, name, label in [
|
</a>
|
||||||
("/", "home", "Dashboard"),
|
</li>
|
||||||
("/knowledge", "book", "Knowledge"),
|
{% if user.admin %}
|
||||||
("/content", "document", "Content"),
|
<li>
|
||||||
("/chat", "chat", "Chat"),
|
<a hx-boost="true" href="/admin"
|
||||||
("/search", "search", "Search"),
|
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200">
|
||||||
("/scratchpad", "scratchpad", "Scratchpad")
|
{% include "icons/wrench_screwdriver_icon.html" %}
|
||||||
] %}
|
<span class="uppercase tracking-wide">Admin</span>
|
||||||
<li>
|
</a>
|
||||||
<a hx-boost="true" href="{{ url }}" class="nb-btn w-full justify-start gap-3 bg-base-100 hover:bg-base-200">
|
</li>
|
||||||
{{ icon(name) }}
|
{% endif %}
|
||||||
<span class="uppercase tracking-wide">{{ label }}</span>
|
<li>
|
||||||
</a>
|
<a hx-boost="true" href="/signout"
|
||||||
</li>
|
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200 border-error text-error">
|
||||||
{% endfor %}
|
{% include "icons/logout_icon.html" %}
|
||||||
<li>
|
<span class="uppercase tracking-wide">Logout</span>
|
||||||
<button class="nb-btn nb-cta w-full flex items-center gap-3 justify-start mt-2" hx-get="/ingress-form"
|
</a>
|
||||||
hx-target="#modal" hx-swap="innerHTML">{% include "icons/send_icon.html" %} Add
|
</li>
|
||||||
Content</button>
|
{% endblock %}
|
||||||
</li>
|
|
||||||
<div class="u-hairline mt-4"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- === MIDDLE SCROLLABLE SECTION === -->
|
|
||||||
<span class="px-4 py-2 font-semibold tracking-wide">Recent Chats</span>
|
|
||||||
<div class="flex-1 overflow-y-auto space-y-1 custom-scrollbar">
|
|
||||||
{% if conversation_archive is defined and conversation_archive %}
|
|
||||||
{% for conversation in conversation_archive %}
|
|
||||||
<li id="conversation-{{ conversation.id }}">
|
|
||||||
{% if edit_conversation_id == conversation.id %}
|
|
||||||
<!-- Edit mode -->
|
|
||||||
<form hx-patch="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
|
|
||||||
class="flex items-center gap-1 px-2 py-2 max-w-72 relative">
|
|
||||||
<input type="text" name="title" value="{{ conversation.title }}" class="nb-input nb-input-sm max-w-52" />
|
|
||||||
<div class="flex gap-0.5 absolute right-2">
|
|
||||||
<button type="submit" class="btn btn-ghost btn-xs !p-0">{% include "icons/check_icon.html" %}</button>
|
|
||||||
<button type="button" hx-get="/chat/sidebar" hx-target=".drawer-side" hx-swap="outerHTML"
|
|
||||||
class="btn btn-ghost btn-xs !p-0">
|
|
||||||
{% include "icons/x_icon.html" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<!-- View mode -->
|
|
||||||
<div class="flex w-full pl-4 pr-2 py-2">
|
|
||||||
<a hx-boost="true" href="/chat/{{ conversation.id }}" class="flex-grow text-sm truncate">
|
|
||||||
<span>{{ conversation.title }}</span>
|
|
||||||
</a>
|
|
||||||
<div class="flex items-center gap-0.5 ml-2">
|
|
||||||
<button hx-get="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
|
|
||||||
class="btn btn-ghost btn-xs">
|
|
||||||
{% include "icons/edit_icon.html" %}
|
|
||||||
</button>
|
|
||||||
<button hx-delete="/chat/{{ conversation.id }}" hx-target=".drawer-side" hx-swap="outerHTML"
|
|
||||||
hx-confirm="Are you sure you want to delete this chat?" class="btn btn-ghost btn-xs">
|
|
||||||
{% include "icons/delete_icon.html" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- === BOTTOM FIXED SECTION === -->
|
|
||||||
<div class="px-2 pb-4 space-y-3">
|
|
||||||
<li>
|
|
||||||
<a hx-boost="true" href="/account"
|
|
||||||
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200">
|
|
||||||
{% include "icons/user_icon.html" %}
|
|
||||||
<span class="uppercase tracking-wide">Account</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% if user.admin %}
|
|
||||||
<li>
|
|
||||||
<a hx-boost="true" href="/admin"
|
|
||||||
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200">
|
|
||||||
{% include "icons/wrench_screwdriver_icon.html" %}
|
|
||||||
<span class="uppercase tracking-wide">Admin</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li>
|
|
||||||
<a hx-boost="true" href="/signout"
|
|
||||||
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200 border-error text-error">
|
|
||||||
{% include "icons/logout_icon.html" %}
|
|
||||||
<span class="uppercase tracking-wide">Logout</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</div>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
#![allow(
|
#![allow(clippy::missing_docs_in_private_items, clippy::result_large_err)]
|
||||||
clippy::missing_docs_in_private_items,
|
|
||||||
clippy::result_large_err
|
|
||||||
)]
|
|
||||||
|
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|||||||
@@ -182,9 +182,7 @@ impl IngestionPipeline {
|
|||||||
.saturating_sub(1)
|
.saturating_sub(1)
|
||||||
.min(tuning.retry_backoff_cap_exponent);
|
.min(tuning.retry_backoff_cap_exponent);
|
||||||
let multiplier = 2_u64.pow(capped_attempt);
|
let multiplier = 2_u64.pow(capped_attempt);
|
||||||
let delay = tuning
|
let delay = tuning.retry_base_delay_secs.saturating_mul(multiplier);
|
||||||
.retry_base_delay_secs
|
|
||||||
.saturating_mul(multiplier);
|
|
||||||
|
|
||||||
Duration::from_secs(delay.min(tuning.retry_max_delay_secs))
|
Duration::from_secs(delay.min(tuning.retry_max_delay_secs))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,8 +184,9 @@ impl PipelineServices for DefaultPipelineServices {
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let config =
|
let config = retrieval_pipeline::RetrievalConfig::for_search(
|
||||||
retrieval_pipeline::RetrievalConfig::for_search(retrieval_pipeline::SearchTarget::EntitiesOnly);
|
retrieval_pipeline::SearchTarget::EntitiesOnly,
|
||||||
|
);
|
||||||
match retrieval_pipeline::retrieve_entities(
|
match retrieval_pipeline::retrieve_entities(
|
||||||
&self.db,
|
&self.db,
|
||||||
&self.openai_client,
|
&self.openai_client,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "main"
|
name = "main"
|
||||||
version = "0.2.7"
|
version = "1.0.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
repository = "https://github.com/perstarkse/minne"
|
repository = "https://github.com/perstarkse/minne"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use api_router::{api_routes_v1, api_state::ApiState};
|
|||||||
use axum::{extract::FromRef, Router};
|
use axum::{extract::FromRef, Router};
|
||||||
use common::{
|
use common::{
|
||||||
storage::{
|
storage::{
|
||||||
db::SurrealDbClient, indexes::ensure_runtime_indexes, store::StorageManager,
|
db::SurrealDbClient,
|
||||||
|
indexes::ensure_runtime_indexes,
|
||||||
|
store::StorageManager,
|
||||||
types::{
|
types::{
|
||||||
knowledge_entity::KnowledgeEntity, system_settings::SystemSettings,
|
knowledge_entity::KnowledgeEntity, system_settings::SystemSettings,
|
||||||
text_chunk::TextChunk,
|
text_chunk::TextChunk,
|
||||||
@@ -75,22 +77,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// Re-embed text chunks
|
// Re-embed text chunks
|
||||||
info!("Re-embedding TextChunks");
|
info!("Re-embedding TextChunks");
|
||||||
if let Err(e) = TextChunk::update_all_embeddings_with_provider(
|
if let Err(e) =
|
||||||
&db,
|
TextChunk::update_all_embeddings_with_provider(&db, &embedding_provider).await
|
||||||
&embedding_provider,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
error!("Failed to re-embed TextChunks: {}. Search results may be stale.", e);
|
error!(
|
||||||
|
"Failed to re-embed TextChunks: {}. Search results may be stale.",
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-embed knowledge entities
|
// Re-embed knowledge entities
|
||||||
info!("Re-embedding KnowledgeEntities");
|
info!("Re-embedding KnowledgeEntities");
|
||||||
if let Err(e) = KnowledgeEntity::update_all_embeddings_with_provider(
|
if let Err(e) =
|
||||||
&db,
|
KnowledgeEntity::update_all_embeddings_with_provider(&db, &embedding_provider).await
|
||||||
&embedding_provider,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
error!(
|
error!(
|
||||||
"Failed to re-embed KnowledgeEntities: {}. Search results may be stale.",
|
"Failed to re-embed KnowledgeEntities: {}. Search results may be stale.",
|
||||||
@@ -117,6 +116,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
config.clone(),
|
config.clone(),
|
||||||
reranker_pool.clone(),
|
reranker_pool.clone(),
|
||||||
embedding_provider.clone(),
|
embedding_provider.clone(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -285,6 +285,7 @@ mod tests {
|
|||||||
config.clone(),
|
config.clone(),
|
||||||
None,
|
None,
|
||||||
embedding_provider,
|
embedding_provider,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("failed to build html state");
|
.expect("failed to build html state");
|
||||||
|
|||||||
@@ -51,9 +51,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let storage = StorageManager::new(&config).await?;
|
let storage = StorageManager::new(&config).await?;
|
||||||
|
|
||||||
// Create embedding provider based on config
|
// Create embedding provider based on config
|
||||||
let embedding_provider = Arc::new(
|
let embedding_provider =
|
||||||
EmbeddingProvider::from_config(&config, Some(openai_client.clone())).await?,
|
Arc::new(EmbeddingProvider::from_config(&config, Some(openai_client.clone())).await?);
|
||||||
);
|
|
||||||
info!(
|
info!(
|
||||||
embedding_backend = ?config.embedding_backend,
|
embedding_backend = ?config.embedding_backend,
|
||||||
embedding_dimension = embedding_provider.dimension(),
|
embedding_dimension = embedding_provider.dimension(),
|
||||||
@@ -72,6 +71,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
config.clone(),
|
config.clone(),
|
||||||
reranker_pool,
|
reranker_pool,
|
||||||
embedding_provider,
|
embedding_provider,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use common::{
|
use common::{
|
||||||
storage::db::SurrealDbClient, storage::store::StorageManager,
|
storage::db::SurrealDbClient,
|
||||||
|
storage::store::StorageManager,
|
||||||
utils::{config::get_config, embedding::EmbeddingProvider},
|
utils::{config::get_config, embedding::EmbeddingProvider},
|
||||||
};
|
};
|
||||||
use ingestion_pipeline::{pipeline::IngestionPipeline, run_worker_loop};
|
use ingestion_pipeline::{pipeline::IngestionPipeline, run_worker_loop};
|
||||||
@@ -40,9 +41,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let reranker_pool = RerankerPool::maybe_from_config(&config)?;
|
let reranker_pool = RerankerPool::maybe_from_config(&config)?;
|
||||||
|
|
||||||
// Create embedding provider based on config
|
// Create embedding provider based on config
|
||||||
let embedding_provider = Arc::new(
|
let embedding_provider =
|
||||||
EmbeddingProvider::from_config(&config, Some(openai_client.clone())).await?,
|
Arc::new(EmbeddingProvider::from_config(&config, Some(openai_client.clone())).await?);
|
||||||
);
|
|
||||||
info!(
|
info!(
|
||||||
embedding_backend = ?config.embedding_backend,
|
embedding_backend = ?config.embedding_backend,
|
||||||
"Embedding provider initialized for worker"
|
"Embedding provider initialized for worker"
|
||||||
@@ -51,17 +51,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Create global storage manager
|
// Create global storage manager
|
||||||
let storage = StorageManager::new(&config).await?;
|
let storage = StorageManager::new(&config).await?;
|
||||||
|
|
||||||
let ingestion_pipeline = Arc::new(
|
let ingestion_pipeline = Arc::new(IngestionPipeline::new(
|
||||||
IngestionPipeline::new(
|
db.clone(),
|
||||||
db.clone(),
|
openai_client.clone(),
|
||||||
openai_client.clone(),
|
config,
|
||||||
config,
|
reranker_pool,
|
||||||
reranker_pool,
|
storage,
|
||||||
storage,
|
embedding_provider,
|
||||||
embedding_provider,
|
)?);
|
||||||
)
|
|
||||||
?,
|
|
||||||
);
|
|
||||||
|
|
||||||
run_worker_loop(db, ingestion_pipeline).await
|
run_worker_loop(db, ingestion_pipeline).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ mod tests {
|
|||||||
use common::storage::types::knowledge_relationship::KnowledgeRelationship;
|
use common::storage::types::knowledge_relationship::KnowledgeRelationship;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_find_entities_by_relationship_by_id() {
|
async fn test_find_entities_by_relationship_by_id() {
|
||||||
// Setup in-memory database for testing
|
// Setup in-memory database for testing
|
||||||
|
|||||||
@@ -301,9 +301,14 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("Search strategy retrieval failed");
|
.expect("Search strategy retrieval failed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(results, StrategyOutput::Search(_)),
|
||||||
|
"expected Search output, got {:?}",
|
||||||
|
results
|
||||||
|
);
|
||||||
let search_result = match results {
|
let search_result = match results {
|
||||||
StrategyOutput::Search(sr) => sr,
|
StrategyOutput::Search(sr) => sr,
|
||||||
other => panic!("expected Search output, got {:?}", other),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Should return chunks (entities may be empty if none stored)
|
// Should return chunks (entities may be empty if none stored)
|
||||||
@@ -312,7 +317,10 @@ mod tests {
|
|||||||
"Search strategy should return chunks"
|
"Search strategy should return chunks"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
search_result.chunks.iter().any(|c| c.chunk.chunk.contains("Tokio")),
|
search_result
|
||||||
|
.chunks
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.chunk.chunk.contains("Tokio")),
|
||||||
"Search results should contain relevant chunks"
|
"Search results should contain relevant chunks"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ use std::time::{Duration, Instant};
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use stages::PipelineContext;
|
use stages::PipelineContext;
|
||||||
use strategies::{DefaultStrategyDriver, IngestionDriver, RelationshipSuggestionDriver, SearchStrategyDriver};
|
use strategies::{
|
||||||
|
DefaultStrategyDriver, IngestionDriver, RelationshipSuggestionDriver, SearchStrategyDriver,
|
||||||
|
};
|
||||||
|
|
||||||
// Export StrategyOutput publicly from this module
|
// Export StrategyOutput publicly from this module
|
||||||
// (it's defined in lib.rs but we re-export it here)
|
// (it's defined in lib.rs but we re-export it here)
|
||||||
|
|||||||
@@ -10,14 +10,10 @@ use common::{
|
|||||||
};
|
};
|
||||||
use fastembed::RerankResult;
|
use fastembed::RerankResult;
|
||||||
use futures::{stream::FuturesUnordered, StreamExt};
|
use futures::{stream::FuturesUnordered, StreamExt};
|
||||||
use std::{
|
use std::{cmp::Ordering, collections::HashMap};
|
||||||
cmp::Ordering,
|
|
||||||
collections::HashMap,
|
|
||||||
};
|
|
||||||
use tracing::{debug, instrument, warn};
|
use tracing::{debug, instrument, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
||||||
graph::find_entities_by_relationship_by_id,
|
graph::find_entities_by_relationship_by_id,
|
||||||
reranking::RerankerLease,
|
reranking::RerankerLease,
|
||||||
scoring::{
|
scoring::{
|
||||||
@@ -444,7 +440,6 @@ pub async fn expand_graph(ctx: &mut PipelineContext<'_>) -> Result<(), AppError>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[instrument(level = "trace", skip_all)]
|
#[instrument(level = "trace", skip_all)]
|
||||||
pub async fn rerank(ctx: &mut PipelineContext<'_>) -> Result<(), AppError> {
|
pub async fn rerank(ctx: &mut PipelineContext<'_>) -> Result<(), AppError> {
|
||||||
let mut applied = false;
|
let mut applied = false;
|
||||||
@@ -572,8 +567,6 @@ pub async fn collect_vector_chunks(ctx: &mut PipelineContext<'_>) -> Result<(),
|
|||||||
// .filter(|c| c.scores.vector.is_some() && c.scores.fts.is_some())
|
// .filter(|c| c.scores.vector.is_some() && c.scores.fts.is_some())
|
||||||
// .count();
|
// .count();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
top_fused_scores = ?vector_chunks.iter().take(5).map(|c| c.fused).collect::<Vec<_>>(),
|
top_fused_scores = ?vector_chunks.iter().take(5).map(|c| c.fused).collect::<Vec<_>>(),
|
||||||
"Fused scores after RRF ordering"
|
"Fused scores after RRF ordering"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user