Compare commits
26 Commits
47850efd2a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 18bca027de | |||
| 36bca94840 | |||
| 71b48d940f | |||
| 58945a4324 | |||
| 4d1541412b | |||
| 250fec1f06 | |||
| 12188f3ad2 | |||
| 9589141521 | |||
| c88e52e0ef | |||
| 76e4461b38 | |||
| 28aa40c42a | |||
| d9eba53926 | |||
| 091a851064 | |||
| 81319f83d4 | |||
| fc11b73319 | |||
| 46f1d6bf4e | |||
| 6f410e29d2 | |||
| 7a151b7d50 | |||
| 6988997144 | |||
| c798f1220c | |||
| dc55d0a8c9 | |||
| 3d1ede26ca | |||
| d57f009b10 | |||
| 1c91c336a9 | |||
| 757f26669a | |||
| 7fccb926db |
74
CLAUDE.md
Normal file
74
CLAUDE.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Running the App
|
||||
|
||||
```bash
|
||||
# Development
|
||||
./venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080 --reload
|
||||
|
||||
# Production (via systemd)
|
||||
sudo systemctl restart jarvischat
|
||||
|
||||
# Direct run
|
||||
./venv/bin/python app.py
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
# Also requires: psutil jinja2 python-multipart (not in requirements.txt)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Single-file FastAPI backend (`app.py`) + single-template frontend (`templates/index.html`). No build step. SQLite database auto-created at `jarvischat.db` on first run.
|
||||
|
||||
### Request Flow: `/api/chat`
|
||||
|
||||
1. User message saved to DB → conversation created if new
|
||||
2. `build_system_prompt()` assembles: profile + FTS5 memory search results + preset prompt
|
||||
3. Streamed to Ollama (`/api/chat`, `stream: true`, `logprobs: true`) via SSE
|
||||
4. **Auto web search trigger**: if perplexity > 15.0 OR response matches `REFUSAL_PATTERNS`, re-queries Ollama with SearXNG results prepended to system prompt
|
||||
5. Final response saved to DB; SSE `done` event sent with perplexity + tokens/sec
|
||||
|
||||
### Request Flow: `/api/search` (explicit search)
|
||||
|
||||
Bypasses perplexity/refusal detection entirely — queries SearXNG directly then asks Ollama to summarize with results as system context.
|
||||
|
||||
### Memory System
|
||||
|
||||
FTS5 virtual table (`memories`) in SQLite. `search_memories()` uses BM25 ranking. `process_remember_command()` intercepts "remember that..." / "forget about..." before the message reaches Ollama and returns a confirmation string. Topic auto-detection via keyword matching in `detect_topic()`.
|
||||
|
||||
### Key Constants (top of `app.py`)
|
||||
|
||||
- `OLLAMA_BASE` — `http://localhost:11434`
|
||||
- `SEARXNG_BASE` — `http://localhost:8888`
|
||||
- `PERPLEXITY_THRESHOLD` — `15.0` (controls auto-search sensitivity)
|
||||
- `DEFAULT_MODEL` — `llama3.1:latest`
|
||||
|
||||
### External Services
|
||||
|
||||
- **Ollama** — required, must be running on port 11434
|
||||
- **SearXNG** — optional, port 8888; `GET /api/search/status` probes availability
|
||||
- **wttr.in** — weather shortcut in `query_searxng()`, bypasses SearXNG for weather queries
|
||||
- **rocm-smi** — AMD GPU stats via subprocess; gracefully degrades if not available
|
||||
|
||||
### Database
|
||||
|
||||
`get_db()` opens a new connection per request (no connection pool). `init_db()` runs at startup via the FastAPI `lifespan` handler. The `profile` table uses a singleton row (`id = 1`). Default settings are seeded but never overwritten by `init_db()`.
|
||||
|
||||
### SSE Protocol
|
||||
|
||||
All streaming endpoints yield `data: {json}\n\n`. Key event shapes:
|
||||
- `{token, conversation_id}` — streaming token
|
||||
- `{searching: true}` — web search triggered
|
||||
- `{search_results: N}` — N results retrieved
|
||||
- `{done: true, perplexity, tokens_per_sec, searched?}` — terminal event
|
||||
- `{error: "..."}` — error event
|
||||
|
||||
### Deployment
|
||||
|
||||
Runs as systemd service under user `jarvischat`, working directory `/opt/jarvischat`. Logs via syslog (`journalctl -u jarvischat`).
|
||||
51
docs/copilot-context-loss-incident-2026-04-21.md
Normal file
51
docs/copilot-context-loss-incident-2026-04-21.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Copilot Chat Incident Report: Context Loss After Project Context Change
|
||||
|
||||
Date observed: 2026-04-21
|
||||
Reporter: Michael Shallop (Gramps)
|
||||
Environment: VS Code on Linux, GitHub Copilot Chat extension present
|
||||
|
||||
## Summary
|
||||
Switching/loading project context in the VS Code project window caused Copilot Chat conversational context to reset. This resulted in loss of recently generated conclusion/plan data that was intended to be implemented immediately after loading the new project.
|
||||
|
||||
## Impact
|
||||
- Lost actionable conclusions from the active design/planning thread.
|
||||
- Interrupted workflow at a critical handoff point (planning -> implementation).
|
||||
- Forced reconstruction from memory instead of exact prior content.
|
||||
- Increased risk of omissions and rework.
|
||||
|
||||
## Reproduction Steps
|
||||
1. Have an active Copilot Chat conversation containing planning/conclusion details.
|
||||
2. Load or switch project context in the current project window.
|
||||
3. Return to Copilot Chat and continue the thread.
|
||||
4. Observe that prior context is no longer available in-chat as expected.
|
||||
|
||||
## Expected Behavior
|
||||
- Prior active conversation context should remain available, or
|
||||
- The user should be prompted before context-destructive operations, and
|
||||
- Recovery path should be obvious and reliable.
|
||||
|
||||
## Actual Behavior
|
||||
- Current chat context was effectively reset.
|
||||
- The previously concluded upgrade notes were not recoverable from active context.
|
||||
- Local transcript/debug artifacts did not provide the full prior thread needed.
|
||||
|
||||
## Severity
|
||||
High (workflow-breaking for planning-heavy sessions)
|
||||
|
||||
## User-visible Failure Mode
|
||||
The user lost conclusion data that was intended for immediate implementation once the new project loaded.
|
||||
|
||||
## Suggested Fixes
|
||||
1. Preserve active chat state across workspace/project context changes by default.
|
||||
2. Show a blocking warning before any action that can drop active conversation state.
|
||||
3. Add one-click export/snapshot of current conversation before context switch.
|
||||
4. Improve transcript durability and discoverability for immediate recovery.
|
||||
5. Add explicit session continuity indicator so users can verify state retention.
|
||||
|
||||
## Notes
|
||||
- This incident occurred in a real implementation workflow and caused direct productivity loss.
|
||||
- Regression tests should include workspace switch/load scenarios with active chat state.
|
||||
|
||||
## Escalation Constraint
|
||||
- Current product constraints prevented the assistant from directly self-reporting this incident to the Copilot/VS Code dev team from within the chat runtime.
|
||||
- User feedback to include verbatim: "it is idiotic to keep you from self-reporting issues like this."
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 219 KiB |
165
docs/wiki/Developer-Architecture.md
Normal file
165
docs/wiki/Developer-Architecture.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Developer Architecture Guide
|
||||
|
||||
This document explains how JarvisChat is structured, why key guardrails exist, and what the test suite validates.
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
JarvisChat is a single-process FastAPI service with a Jinja2 frontend and SQLite persistence.
|
||||
|
||||
Primary files:
|
||||
|
||||
- `app.py`: API, middleware, streaming/chat logic, auth, memory, skills, and DB bootstrap
|
||||
- `templates/index.html`: main WebUX, settings panels, auth flow, streaming UI handlers
|
||||
- `jarvischat.db`: runtime SQLite database created and migrated at startup
|
||||
|
||||
Core runtime integrations:
|
||||
|
||||
- Ollama for chat/model interaction
|
||||
- SearXNG for web search (optional)
|
||||
- wttr.in for weather shortcut queries
|
||||
- rocm-smi for GPU stats when available
|
||||
|
||||
## 2. Request/Response Architecture
|
||||
|
||||
### 2.1 Chat Pipeline (`/api/chat`)
|
||||
|
||||
1. Validate session, role, origin, rate, and payload limits in middleware
|
||||
2. Persist user message and conversation metadata
|
||||
3. Build system prompt from enabled profile, memory context, and active skills metadata
|
||||
4. Stream model response over SSE token-by-token
|
||||
5. Evaluate uncertainty/refusal; if needed, trigger search augmentation and stream augmented result
|
||||
6. Persist final assistant message and emit terminal SSE event
|
||||
|
||||
### 2.2 Explicit Search Pipeline (`/api/search`)
|
||||
|
||||
1. Persist search-as-message into the target/new conversation
|
||||
2. Emit `searching` SSE event
|
||||
3. Pull web results from SearXNG
|
||||
4. Summarize with Ollama via SSE stream
|
||||
5. Persist summary and emit `done` event (plus raw results payload)
|
||||
|
||||
### 2.3 Settings/Control Surface
|
||||
|
||||
- Profile, presets, memory, conversation management, and settings APIs
|
||||
- Skills APIs for phase-1 registry and enable/disable controls
|
||||
- Auth/session APIs for guest/admin role handling and keepalive
|
||||
|
||||
## 3. Data Model (SQLite)
|
||||
|
||||
Key tables:
|
||||
|
||||
- `conversations`: conversation headers and timestamps
|
||||
- `messages`: ordered chat history entries
|
||||
- `profile`: singleton row for injected profile prompt
|
||||
- `settings`: runtime toggles and selected defaults
|
||||
- `system_presets`: named reusable system prompts
|
||||
- `skills`: per-skill enabled state and timestamp
|
||||
- `memories` (FTS5 virtual table): searchable user memory facts
|
||||
|
||||
Design notes:
|
||||
|
||||
- Startup is idempotent: tables are created if missing and defaults seeded only when absent
|
||||
- No connection pool: each request opens a short-lived SQLite connection
|
||||
|
||||
## 4. Security Implementations
|
||||
|
||||
This section documents explicit controls currently in code.
|
||||
|
||||
### 4.1 Auth Model
|
||||
|
||||
- Guest session is default for conversational access
|
||||
- Admin unlock uses 4-digit PIN and creates admin-capable session
|
||||
- Admin required for write/destructive routes
|
||||
- Session heartbeat/timeout and explicit logout/revoke flow
|
||||
|
||||
### 4.2 PIN and Session Hardening
|
||||
|
||||
- Admin PIN hashed with PBKDF2-HMAC-SHA256 + salt
|
||||
- Failed PIN attempts tracked per client IP
|
||||
- Lockout window enforced after max failed attempts
|
||||
|
||||
### 4.3 Browser and API Abuse Controls
|
||||
|
||||
- Origin checks on state-changing requests
|
||||
- Rate limiting by endpoint category and identity (IP/session)
|
||||
- Payload size limits per route class
|
||||
- Settings key allowlist to block arbitrary configuration injection
|
||||
- IP allowlist/CIDR gate with optional trusted proxy forwarding mode
|
||||
|
||||
### 4.4 Output and Error Safety
|
||||
|
||||
- Search result URLs sanitized to `http`/`https` only
|
||||
- Client-safe error envelopes with incident key correlation
|
||||
- Full stack traces and diagnostic metadata logged server-side only
|
||||
|
||||
### 4.5 Operational Auditability
|
||||
|
||||
- Structured audit events for auth actions, admin operations, and guardrail denials
|
||||
- Incident logs include event type, key, path/method context, and runtime metadata
|
||||
|
||||
## 5. Skills Framework (Phase 1)
|
||||
|
||||
Goal: introduce a governed skills control plane inside the local JarvisChat sandbox.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- Built-in skill registry defined server-side
|
||||
- Per-skill enable/disable persisted in DB
|
||||
- Global `skills_enabled` master toggle in settings
|
||||
- Active skills injected into system prompt with bounded text budget
|
||||
- API endpoints to list skills, list active skills, and toggle skill state
|
||||
- WebUX settings panel to control master/per-skill toggles
|
||||
|
||||
Non-goals in phase 1:
|
||||
|
||||
- No unrestricted shell/tool execution
|
||||
- No external connector execution (filesystem, Gmail, etc.)
|
||||
|
||||
## 6. Testing Strategy and Validation Intent
|
||||
|
||||
The test suite validates both behavior and guardrail assumptions.
|
||||
|
||||
### 6.1 What We Test
|
||||
|
||||
- Auth capability separation (guest vs admin)
|
||||
- URL sanitization safety for outbound links
|
||||
- Rate and payload guardrails
|
||||
- IP allowlist behavior
|
||||
- Safe error envelope behavior and SSE error leakage prevention
|
||||
- Streaming chat/search and memory command paths
|
||||
- Skills framework toggles and prompt-injection behavior
|
||||
|
||||
### 6.2 Why These Tests Matter
|
||||
|
||||
- Confirms security controls are active and regression-resistant
|
||||
- Ensures streaming UX protocol remains stable (`token`, `searching`, `done`, `error`)
|
||||
- Verifies policy intent: dangerous actions require admin capability
|
||||
- Validates new features preserve prior guarantees
|
||||
|
||||
### 6.3 Internal Process Validation
|
||||
|
||||
For substantive changes, Definition of Done includes:
|
||||
|
||||
1. Implement code change
|
||||
2. Add/adjust tests proving behavior and guardrail intent
|
||||
3. Update README release notes for user-facing impact
|
||||
4. Update wiki architecture/security/testing docs for maintainers
|
||||
5. Validate with targeted test runs before merge/deploy
|
||||
|
||||
This process is intentionally explicit so design decisions remain auditable over time.
|
||||
|
||||
## 7. Deployment and Operations Notes
|
||||
|
||||
- Primary deployment target: local/homelab systemd service
|
||||
- Required dependency: Ollama
|
||||
- Optional dependency: SearXNG
|
||||
- Recommended log review path: system journal for startup, guardrail denials, and incidents
|
||||
|
||||
## 8. Contribution Guidance
|
||||
|
||||
When adding a feature:
|
||||
|
||||
1. Define security posture first (who can execute, what can fail, and failure mode)
|
||||
2. Implement smallest safe slice with clear limits
|
||||
3. Add tests that prove both happy path and guardrail path
|
||||
4. Update this wiki and README in the same change
|
||||
23
docs/wiki/Home.md
Normal file
23
docs/wiki/Home.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# JarvisChat Developer Wiki
|
||||
|
||||
This wiki is the developer-facing architecture and process reference for JarvisChat.
|
||||
|
||||
## Audience
|
||||
|
||||
- Contributors maintaining backend, frontend, security posture, and deployment process
|
||||
- Operators validating local or homelab deployments
|
||||
|
||||
## Start Here
|
||||
|
||||
- Architecture and components: [Developer-Architecture.md](Developer-Architecture.md)
|
||||
- Active implementation backlog: [current-wip.md](current-wip.md)
|
||||
|
||||
## Scope and Support Model
|
||||
|
||||
JarvisChat is designed for local and trusted-LAN operation.
|
||||
|
||||
The code may technically function against external or commercial endpoints, but this deployment mode is not a supported target in this project.
|
||||
|
||||
## Wiki Maintenance Rule
|
||||
|
||||
When architecture, security behavior, or test policy changes, update this wiki in the same change set as code and tests.
|
||||
84
docs/wiki/current-wip.md
Normal file
84
docs/wiki/current-wip.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# JarvisChat Current WiP Backlog
|
||||
|
||||
Last updated: 2026-04-27
|
||||
Owner: Gramps + Copilot
|
||||
Scope: issues, bugs, security exposures, and feature enhancements.
|
||||
|
||||
Total identified items: 27
|
||||
|
||||
## Priority Definitions
|
||||
- P0: Critical risk or data-loss/security exposure; do first.
|
||||
- P1: High impact reliability/correctness work.
|
||||
- P2: Important feature/UX improvements.
|
||||
- P3: Nice-to-have polish.
|
||||
|
||||
## Top 10 (Urgency Order)
|
||||
1. [P0][DONE] Add authentication/authorization for all write and admin endpoints.
|
||||
2. [P0][DONE] Add CSRF/origin protection for browser-initiated state-changing requests.
|
||||
3. [P0][DONE] Block unsafe URL schemes in rendered search-result links (e.g., javascript:).
|
||||
4. [P0][DONE] Add rate limiting and request body size limits for chat/search/profile APIs.
|
||||
5. [P1][DONE] Restrict settings updates to an allowlist of valid keys.
|
||||
6. [P1] Add pagination + hard caps on list endpoints (memories, conversations, message history).
|
||||
7. [P1][DONE] Stop returning raw exception text to clients; use safe error envelopes.
|
||||
8. [P1][DONE] Add automated tests for chat streaming, auto-search trigger, and memory command paths.
|
||||
9. [P2][DONE] Implement skills/tool-call framework (MCP-style) with per-skill enable controls.
|
||||
10. [P2] Implement heartbeat/check-in pipeline with scheduler + summary endpoint.
|
||||
|
||||
## Item 1 Executive Summary (Scope + Security)
|
||||
|
||||
- Status: Complete. Guest/admin capability split implemented with admin-only write enforcement, origin checks on state-changing requests, audit logging, and endpoint capability tests.
|
||||
|
||||
- Decision: JarvisChat is local-first by design. Primary mode is same-host Ollama; optional mode allows RFC1918 LAN endpoints only.
|
||||
- Constraint: Public Internet AI endpoints are out of scope unless explicitly enabled in a future advanced mode.
|
||||
- Risk: Even on LAN, unauthenticated write/admin endpoints permit unauthorized data tampering and deletion.
|
||||
- Requirement: Add mandatory admin authentication for all POST/PUT/DELETE routes and destructive actions.
|
||||
- Authentication shape (scope-locked): two capability tiers only: guest (chat-only) and admin (4-digit PIN unlock).
|
||||
- Scope guardrail: Avoid full RBAC. Keep capability split minimal: conversational chat for guest, advanced/destructive actions for admin.
|
||||
- Definition of done:
|
||||
1. Auth required on all state-changing endpoints.
|
||||
2. Destructive actions require admin authorization.
|
||||
3. Endpoint configuration rejects non-local/non-RFC1918 AI backends by default.
|
||||
4. Strong rate limiting + lockout controls in place for PIN attempts.
|
||||
5. Security events logged for failed and successful admin actions.
|
||||
|
||||
## Full Backlog (Sorted by Priority)
|
||||
|
||||
### P0 Critical
|
||||
1. Add auth for write/admin endpoints (`POST/PUT/DELETE` routes, mass delete, profile/settings changes).
|
||||
2. Add CSRF or strict origin checks for browser session protection.
|
||||
3. Validate/sanitize outbound href URLs before rendering in HTML (allow http/https only).
|
||||
4. Add per-IP rate limiting on `/api/chat`, `/api/search`, `/api/profile`, `/api/settings`.
|
||||
5. Enforce request size limits (message/profile text and JSON body) to prevent memory abuse.
|
||||
|
||||
### P1 High
|
||||
6. Add settings key allowlist in `/api/settings` to prevent arbitrary key injection.
|
||||
7. Add pagination (`limit`, `offset`) with enforced maximums for list APIs.
|
||||
8. Add DB indexes and query hygiene for scalability (`messages.conversation_id`, timestamps).
|
||||
9. Replace raw exception leakage to clients with generic safe error messages + server-side logs.
|
||||
10. Add request/response timeout and retry policy consistency across external calls.
|
||||
11. Add endpoint-level audit logging for destructive operations.
|
||||
12. Add unit/integration tests for: remember/forget parsing, refusal detection, search fallback, SSE done/error shape.
|
||||
13. Add conversation title sanitization and length constraints.
|
||||
14. Ensure default preset semantics are correct (currently all seeded presets are marked default).
|
||||
15. Add preflight validation for required model/preset selection and block send with clear user guidance instead of timing out.
|
||||
|
||||
### P2 Important Features
|
||||
16. Skills system: load markdown skill files with YAML frontmatter from skills directory.
|
||||
17. Skills registry API: list/enable/disable skills and expose active skills to UI.
|
||||
18. Inject active skill instructions into system prompt with bounded token budget.
|
||||
19. Tool execution guardrails: allowlist, confirmation mode, and execution logs.
|
||||
20. Heartbeat scheduler (cron/systemd timer) for daily check-ins.
|
||||
21. Heartbeat endpoint for generated briefings and anomaly summaries.
|
||||
22. Model info UI panel (description, updated date, best-use purpose).
|
||||
23. Default model selection improvements and persistence validation.
|
||||
24. Hidden model list support (exclude models from dropdown).
|
||||
25. Model update action from UI (trigger controlled model pull).
|
||||
|
||||
### P3 Nice to Have
|
||||
26. Conversation search/filter and export tooling.
|
||||
27. Keyboard shortcuts, retry button, and source-link polish.
|
||||
|
||||
## Maintenance Rules
|
||||
- Keep this file as the single source of truth.
|
||||
- Update item priority/status whenever work starts or completes.
|
||||
- Mirror the Top 10 summary in README and keep counts aligned.
|
||||
355
readme.md
355
readme.md
@@ -1,74 +1,304 @@
|
||||
# ⚡ JarvisChat v1.4.0
|
||||
# ⚡ JarvisChat v1.7.8
|
||||
|
||||

|
||||
|
||||
**A lightweight Ollama coding companion that runs on Python 3.13**
|
||||
**A lightweight Ollama coding companion with persistent memory, web search, and real-time system monitoring.**
|
||||
|
||||
## New in v1.4.0
|
||||
- **FTS5 Memory System**: Say "remember that..." to store facts, they're automatically retrieved by relevance
|
||||
- **Forget command**: Say "forget about..." to remove memories
|
||||
- **Memory toggle**: Enable/disable memory injection from topbar
|
||||
- **Refactored structure**: Separated frontend from backend for maintainability
|
||||
Built with FastAPI + SQLite + Jinja2. Runs on Python 3.13. No Docker required.
|
||||
|
||||
Developer wiki: [docs/wiki/Home.md](docs/wiki/Home.md)
|
||||
|
||||
Core architecture deep-dive: [docs/wiki/Developer-Architecture.md](docs/wiki/Developer-Architecture.md)
|
||||
|
||||
## Security Scope Disclaimer
|
||||
|
||||
JarvisChat is designed for local and home-lab use (same host or trusted LAN).
|
||||
|
||||
JarvisChat may technically work with frontier or commercial AI endpoints, but the author does not recommend or support that usage.
|
||||
|
||||
Supported deployments are contained local/home-lab environments.
|
||||
|
||||
By default, API access is limited to loopback + private LAN CIDRs. You can override with `JARVISCHAT_ALLOWED_CIDRS` (comma-separated CIDRs) and optionally trust reverse-proxy forwarding with `JARVISCHAT_TRUST_X_FORWARDED_FOR=true`.
|
||||
|
||||
If you deploy outside a trusted local subnet, your risk profile changes significantly and the default protections here may be insufficient.
|
||||
|
||||
Use at your own risk. No warranty is provided for Internet-exposed deployments.
|
||||
|
||||
## What's New in v1.7.x
|
||||
|
||||
- **Security hardening suite completed** - request rate limits, payload caps, settings allowlist, safe error envelopes, and LAN CIDR gate controls
|
||||
- **Customer-safe incident handling** - client-facing errors include support-friendly incident keys while full traces remain in server logs
|
||||
- **Streaming and regression test expansion** - automated coverage for SSE chat/search paths, memory remember/forget command handling, and auth/guardrail behavior
|
||||
- **Skills framework (Phase 1)** - built-in local skill registry with per-skill enable controls, API endpoints, and bounded prompt injection
|
||||
- **Skills WebUX controls** - Settings modal now includes a master skills toggle and per-skill toggles for admin users
|
||||
|
||||
## What's New in v1.6.x
|
||||
|
||||
- **Guest/admin capability split** - guest chat by default with 4-digit admin PIN for advanced or destructive operations
|
||||
- **Session + lockout controls** - session lifecycle endpoints, heartbeat, logout/revoke behavior, failed PIN lockout protections, and auth audit events
|
||||
- **Browser request protections** - strict origin checks for state-changing requests and admin-only write enforcement
|
||||
- **Unsafe link protection** - outbound search links sanitized to allow only http/https absolute URLs
|
||||
- **Operational stability fixes** - safer first-boot PIN policy handling and memory-search tokenization fix for punctuation/FTS edge cases
|
||||
|
||||
## What's New in v1.5.0
|
||||
|
||||
- **Explicit Web Search Button** — 🔍 button next to SEND forces a web search, bypassing model uncertainty detection
|
||||
- **Orange Search Styling** — Search results, WEB badge, and search button share consistent orange color scheme
|
||||
- **Expanded Refusal Patterns** — Added "As an AI model", "based on my training data", "I don't have the capability"
|
||||
- **Code cleanup** — Removed unused `JSONResponse` import and dead `raw_results_md` variable
|
||||
- **Bug fixes** — Replaced bare `except` clauses with `except Exception`; corrected `add_memory()` return type to `int | None`; updated `TemplateResponse` call to Starlette's current API signature
|
||||
|
||||
## What's New in v1.4.0
|
||||
|
||||
- **FTS5 Memory System**: Say "remember that..." to store facts — they're automatically retrieved by relevance and injected into context
|
||||
- **Forget Command**: Say "forget about..." to remove memories
|
||||
- **Memory Toggle**: Enable/disable memory injection from topbar or settings
|
||||
- **Multi-file Structure**: Backend and frontend separated for easier maintenance
|
||||
|
||||
## Features
|
||||
|
||||
- **Persistent Memory** — SQLite FTS5 full-text search for fast, relevant memory retrieval
|
||||
- **Web Search** — SearXNG integration for automatic web lookups when the model is uncertain
|
||||
- **Explicit Search** — 🔍 button to force web search without waiting for model uncertainty
|
||||
- **Profile Injection** — Custom system prompt injected into every conversation
|
||||
- **System Presets** — Save and switch between different system prompts
|
||||
- **Real-time Stats** — CPU, RAM, GPU, VRAM monitoring in sidebar
|
||||
- **Token Thermometer** — Visual context window usage indicator
|
||||
- **Streaming Responses** — Server-sent events for real-time token display
|
||||
- **Conversation History** — SQLite-backed chat persistence with mass-delete option
|
||||
- **Model Switching** — Change Ollama models on the fly
|
||||
|
||||
## Current WiP (Prioritized)
|
||||
|
||||
Canonical backlog: [docs/wiki/current-wip.md](docs/wiki/current-wip.md)
|
||||
|
||||
Scope boundary: local-first (same-host Ollama), optional RFC1918 LAN endpoints, no public Internet AI endpoints by default.
|
||||
|
||||
Total identified items: 27
|
||||
|
||||
Top 10 (brief):
|
||||
|
||||
1. P0 [DONE]: Add auth for write/admin endpoints
|
||||
2. P0 [DONE]: Add CSRF/origin protection for state-changing requests
|
||||
3. P0 [DONE]: Block unsafe URL schemes in rendered links
|
||||
4. P0 [DONE]: Add rate limiting and request size limits
|
||||
5. P1 [DONE]: Restrict `/api/settings` updates to allowlisted keys
|
||||
6. P1: Add pagination + hard caps for list APIs
|
||||
7. P1 [DONE]: Replace raw exception leakage with safe client errors
|
||||
8. P1 [DONE]: Add automated tests for streaming/search/memory paths
|
||||
9. P2 [DONE]: Implement MCP-style skills/tool-call framework
|
||||
10. P2: Implement heartbeat/check-in scheduler + summary endpoint
|
||||
|
||||
Item 1 executive summary: keep guest mode for conversational chat, require 4-digit admin PIN for advanced/destructive actions, and enforce local/LAN-only backend policy by default.
|
||||
|
||||
Implementation status: complete (guest session by default + admin unlock + admin-only write enforcement + origin checks + safe-link sanitization + audit logging + rate/payload guardrails + capability tests).
|
||||
|
||||
## TODO
|
||||
|
||||
1. ~~Verify SearXNG and Docker services persist across reboots~~
|
||||
2. Conversation search/filter by keyword
|
||||
3. Export conversation to markdown/text
|
||||
4. Keyboard shortcuts (Ctrl+N new chat, Ctrl+Enter send)
|
||||
5. Retry button on assistant messages
|
||||
6. Source links — clickable links when search used
|
||||
7. Allow conversation renaming
|
||||
8. Multiple profiles — coding/sysadmin/general
|
||||
9. Auto-generate conversation tags (client-side KWIC, top 5, filterable badges)
|
||||
10. Image input support — pull vision model, file input/drag-drop, base64 encode, pass `images` array to Ollama `/api/chat`
|
||||
11. Split-screen option for btop display
|
||||
12. Skills as markdown files — `/opt/jarvischat/skills/`, YAML frontmatter + instructions, injected into context for tool calls
|
||||
13. Heartbeats / proactive check-ins — cron + endpoint for daily briefings, HA anomaly alerts
|
||||
14. Model info button — (i) icon next to Model dropdown, shows div with model description, last updated date, best-use purpose
|
||||
15. Set default model — toggle any model as the default selection
|
||||
16. Hide/remove model from list — exclude models from dropdown
|
||||
17. Update model function — trigger `ollama pull` for selected model from UI
|
||||
18. Add mouseover tooltip to SEND button
|
||||
19. Add preflight validation for required model/preset selection and show a clear warning before send to prevent avoidable timeout loops
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/opt/jarvischat/
|
||||
├── app.py # FastAPI backend (~600 lines)
|
||||
├── app.py # FastAPI backend
|
||||
├── jarvischat.db # SQLite database (auto-created)
|
||||
├── static/
|
||||
│ └── logo.jpg # Your logo (optional)
|
||||
│ └── logo.png # Logo image (optional)
|
||||
└── templates/
|
||||
└── index.html # Frontend
|
||||
```
|
||||
|
||||
## Installation
|
||||
```bash
|
||||
# Backup existing
|
||||
cd /opt/jarvischat
|
||||
cp app.py app.py.bak
|
||||
## Requirements
|
||||
|
||||
# Create directories
|
||||
- Python 3.11+ (tested on 3.13)
|
||||
- Ollama running locally or on network
|
||||
- SearXNG (optional, for web search)
|
||||
|
||||
## Installation
|
||||
|
||||
### Fresh Install
|
||||
|
||||
```bash
|
||||
# Create directory and venv
|
||||
sudo mkdir -p /opt/jarvischat
|
||||
sudo chown $USER:$USER /opt/jarvischat
|
||||
cd /opt/jarvischat
|
||||
python3 -m venv venv
|
||||
|
||||
# Install dependencies
|
||||
./venv/bin/pip install fastapi uvicorn httpx psutil jinja2 python-multipart
|
||||
|
||||
# Set admin PIN before first startup (4 digits)
|
||||
export JARVISCHAT_ADMIN_PIN=4827
|
||||
|
||||
# Create subdirectories
|
||||
mkdir -p templates static
|
||||
|
||||
# Copy new files (from wherever you downloaded them)
|
||||
cp /path/to/new/app.py .
|
||||
cp /path/to/new/templates/index.html templates/
|
||||
# Copy files
|
||||
# (copy app.py to /opt/jarvischat/)
|
||||
# (copy index.html to /opt/jarvischat/templates/)
|
||||
# (copy logo.png to /opt/jarvischat/static/ — optional)
|
||||
```
|
||||
|
||||
# Restart service
|
||||
WARNING: Do not use `1234` as your admin PIN unless you accept weak local security.
|
||||
|
||||
NOTE: First boot now requires `JARVISCHAT_ADMIN_PIN` unless you explicitly opt into insecure fallback with `JARVISCHAT_ALLOW_DEFAULT_PIN=true`.
|
||||
|
||||
### Upgrading from v1.4.x
|
||||
|
||||
```bash
|
||||
cd /opt/jarvischat
|
||||
|
||||
# Backup
|
||||
cp app.py app.py.bak
|
||||
cp templates/index.html templates/index.html.bak
|
||||
|
||||
# Copy new files
|
||||
# (copy app.py, replacing old version)
|
||||
# (copy index.html to templates/)
|
||||
|
||||
# Restart
|
||||
sudo systemctl restart jarvischat
|
||||
```
|
||||
|
||||
## Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/jarvischat.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=JarvisChat - Local Ollama Web Interface
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=jarvischat
|
||||
Group=jarvischat
|
||||
WorkingDirectory=/opt/jarvischat
|
||||
ExecStart=/opt/jarvischat/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable jarvischat
|
||||
sudo systemctl start jarvischat
|
||||
```
|
||||
|
||||
## Memory Commands
|
||||
|
||||
In chat, you can say:
|
||||
- "remember that I prefer Rust over Go" → stores as preference
|
||||
- "remember that JarvisChat runs on port 8080" → stores as infrastructure
|
||||
- "note that the deadline is Friday" → stores as general
|
||||
- "forget about the deadline" → removes matching memories
|
||||
In chat, natural language triggers memory operations:
|
||||
|
||||
Memories are automatically searched and injected based on your message content.
|
||||
| You say | What happens |
|
||||
|---------|--------------|
|
||||
| "remember that I prefer Rust over Go" | Stores as `preference` |
|
||||
| "remember that JarvisChat runs on port 8080" | Stores as `infrastructure` |
|
||||
| "note that the deadline is Friday" | Stores as `general` |
|
||||
| "forget about the deadline" | Removes matching memories |
|
||||
|
||||
Memories are automatically searched based on your message content and injected into the system prompt when relevant.
|
||||
|
||||
### Memory Topics
|
||||
|
||||
Memories are auto-categorized:
|
||||
- `preference` — likes, dislikes, choices
|
||||
- `project` — active work, repos, tasks
|
||||
- `infrastructure` — servers, services, configs
|
||||
- `personal` — name, location, background
|
||||
- `general` — everything else
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Memory
|
||||
- `GET /api/memories` - List all memories
|
||||
- `POST /api/memories` - Add memory `{"fact": "...", "topic": "general"}`
|
||||
- `DELETE /api/memories/{rowid}` - Delete memory
|
||||
- `GET /api/memories/search?q=rust` - Search memories
|
||||
- `GET /api/memories/stats` - Get counts by topic
|
||||
|
||||
### Existing
|
||||
- `GET /api/models` - List Ollama models
|
||||
- `POST /api/chat` - Send message (streaming)
|
||||
- `GET /api/profile` - Get profile
|
||||
- `PUT /api/settings` - Update settings
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/memories` | List all memories |
|
||||
| POST | `/api/memories` | Add memory `{"fact": "...", "topic": "general"}` |
|
||||
| DELETE | `/api/memories/{rowid}` | Delete memory by ID |
|
||||
| GET | `/api/memories/search?q=term` | Search memories |
|
||||
| GET | `/api/memories/stats` | Get counts by topic |
|
||||
|
||||
## Dependencies
|
||||
```bash
|
||||
pip install fastapi uvicorn httpx psutil jinja2 python-multipart --break-system-packages
|
||||
```
|
||||
### Chat & Models
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/models` | List available Ollama models |
|
||||
| POST | `/api/chat` | Send message (streaming SSE) |
|
||||
| POST | `/api/search` | Explicit web search (streaming SSE) |
|
||||
| POST | `/api/show` | Get model info (context size) |
|
||||
| GET | `/api/ps` | Get running models |
|
||||
|
||||
### Settings & Profile
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/profile` | Get profile content |
|
||||
| PUT | `/api/profile` | Update profile |
|
||||
| GET | `/api/profile/default` | Get default profile |
|
||||
| GET | `/api/settings` | Get settings |
|
||||
| PUT | `/api/settings` | Update settings |
|
||||
|
||||
### Conversations
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/conversations` | List conversations |
|
||||
| GET | `/api/conversations/{id}` | Get conversation with messages |
|
||||
| DELETE | `/api/conversations/{id}` | Delete conversation |
|
||||
| DELETE | `/api/conversations` | Delete ALL conversations |
|
||||
|
||||
### Presets
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/presets` | List presets |
|
||||
| POST | `/api/presets` | Create preset |
|
||||
| PUT | `/api/presets/{id}` | Update preset |
|
||||
| DELETE | `/api/presets/{id}` | Delete preset |
|
||||
|
||||
### System
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/stats` | CPU, RAM, GPU, VRAM stats |
|
||||
| GET | `/api/search/status` | SearXNG availability |
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are stored in the `settings` table and include:
|
||||
|
||||
- `profile_enabled` — Inject profile into chats (true/false)
|
||||
- `search_enabled` — Auto web search (true/false)
|
||||
- `memory_enabled` — Memory injection (true/false)
|
||||
- `default_model` — Default Ollama model
|
||||
- `searxng_url` — SearXNG instance URL (default: `http://localhost:8888`)
|
||||
|
||||
## Testing Memory
|
||||
|
||||
```bash
|
||||
# Add a memory via API
|
||||
curl -X POST http://jarvis:8080/api/memories \
|
||||
@@ -78,7 +308,48 @@ curl -X POST http://jarvis:8080/api/memories \
|
||||
# Search memories
|
||||
curl "http://jarvis:8080/api/memories/search?q=docker"
|
||||
|
||||
# Or in chat, just say:
|
||||
# "remember that I hate yaml"
|
||||
# Then ask: "what markup languages should I avoid?"
|
||||
# List all memories
|
||||
curl http://jarvis:8080/api/memories
|
||||
|
||||
# Get stats
|
||||
curl http://jarvis:8080/api/memories/stats
|
||||
```
|
||||
|
||||
Or in chat:
|
||||
1. Say "remember that I hate YAML"
|
||||
2. Later ask "what markup languages should I avoid?"
|
||||
3. JarvisChat will inject the YAML preference into context
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service won't start
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
journalctl -u jarvischat -n 50 --no-pager
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Missing `jinja2`: `./venv/bin/pip install jinja2`
|
||||
- Missing `templates/` directory
|
||||
- Wrong permissions on `/opt/jarvischat`
|
||||
|
||||
### Memory not working
|
||||
|
||||
1. Check memory is enabled (🧠 MEM ON in topbar)
|
||||
2. Verify memories exist: `curl http://jarvis:8080/api/memories`
|
||||
3. Check FTS5 table: `sqlite3 jarvischat.db "SELECT * FROM memories_fts;"`
|
||||
|
||||
### Web search not working
|
||||
|
||||
1. Verify SearXNG is running: `curl http://localhost:8888/search?q=test&format=json`
|
||||
2. Check search status: `curl http://jarvis:8080/api/search/status`
|
||||
3. Ensure JSON format is enabled in SearXNG settings
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Repository
|
||||
|
||||
Gitea: `ssh://gitea@llgit.llamachile.tube:1319/gramps/jarvisChat.git`
|
||||
|
||||
BIN
static/logo.jpg
Normal file
BIN
static/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
@@ -116,6 +116,13 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
.memory-item .memory-delete { color:var(--danger); cursor:pointer; opacity:0.5; }
|
||||
.memory-item .memory-delete:hover { opacity:1; }
|
||||
.memory-stats { font-size:11px; color:var(--text-muted); margin-bottom:10px; font-family:var(--font-mono); }
|
||||
.skills-status { font-size:11px; color:var(--text-muted); margin-bottom:10px; font-family:var(--font-mono); }
|
||||
.skill-item { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:8px 10px; background:var(--bg-tertiary); border-radius:var(--radius); margin-bottom:6px; }
|
||||
.skill-meta { min-width:0; }
|
||||
.skill-name { font-size:12px; color:var(--text-primary); font-family:var(--font-mono); margin-bottom:3px; }
|
||||
.skill-desc { font-size:11px; color:var(--text-muted); line-height:1.4; }
|
||||
.skill-risk { display:inline-block; margin-left:8px; padding:1px 6px; border-radius:10px; font-size:10px; text-transform:uppercase; border:1px solid var(--border); color:var(--text-secondary); }
|
||||
.skill-item.disabled .skill-meta { opacity:0.6; }
|
||||
|
||||
.chat-container { flex:1; overflow-y:auto; padding:20px; display:flex; flex-direction:column; gap:16px; }
|
||||
.welcome-screen { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--text-muted); text-align:center; gap:12px; }
|
||||
@@ -142,7 +149,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
.search-indicator { display:inline-flex; align-items:center; gap:8px; padding:8px 12px; background:rgba(243,156,18,0.15); border:1px solid rgba(243,156,18,0.3); border-radius:var(--radius); color:var(--warning); font-family:var(--font-mono); font-size:12px; margin:8px 0; }
|
||||
.search-indicator .spinner { width:14px; height:14px; border:2px solid rgba(243,156,18,0.3); border-top-color:var(--warning); border-radius:50%; animation:spin 1s linear infinite; }
|
||||
@keyframes spin { to{transform:rotate(360deg)} }
|
||||
.search-badge-inline { display:inline-block; padding:2px 8px; background:rgba(46,204,113,0.15); border:1px solid rgba(46,204,113,0.3); border-radius:10px; color:var(--success); font-family:var(--font-mono); font-size:10px; margin-left:8px; }
|
||||
.search-badge-inline { display:inline-block; padding:2px 8px; background:rgba(243,156,18,0.15); border:1px solid rgba(243,156,18,0.3); border-radius:10px; color:var(--warning); font-family:var(--font-mono); font-size:10px; margin-left:8px; }
|
||||
.memory-badge-inline { display:inline-block; padding:2px 8px; background:rgba(155,89,182,0.15); border:1px solid rgba(155,89,182,0.3); border-radius:10px; color:#9b59b6; font-family:var(--font-mono); font-size:10px; margin-left:8px; }
|
||||
.perplexity-badge { display:inline-block; padding:2px 8px; border-radius:10px; font-family:var(--font-mono); font-size:10px; margin-left:8px; }
|
||||
.perplexity-badge.low { background:rgba(46,204,113,0.15); border:1px solid rgba(46,204,113,0.3); color:var(--success); }
|
||||
@@ -162,6 +169,9 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
.send-btn:hover { background:var(--accent); }
|
||||
.stop-btn { padding:12px 20px; background:var(--danger); border:none; border-radius:var(--radius); color:#fff; font-family:var(--font-mono); font-size:13px; font-weight:600; cursor:pointer; }
|
||||
.stop-btn:hover { background:var(--danger-hover); }
|
||||
.search-btn { padding:12px 14px; background:var(--warning); border:none; border-radius:var(--radius); color:#fff; font-size:16px; cursor:pointer; transition:background 0.2s; }
|
||||
.search-btn:hover { background:#e67e22; }
|
||||
.search-btn:disabled { background:var(--text-muted); cursor:not-allowed; }
|
||||
|
||||
.token-thermometer { display:flex; flex-direction:column; align-items:center; gap:4px; }
|
||||
.thermometer-bar { width:12px; height:80px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; position:relative; overflow:hidden; }
|
||||
@@ -170,16 +180,51 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
.token-info.warning { color:var(--warning); }
|
||||
.token-info.danger { color:var(--danger); }
|
||||
|
||||
.message.assistant.search-result .content { background:rgba(243,156,18,0.08); border:1px solid rgba(243,156,18,0.2); border-radius:var(--radius); padding:12px; }
|
||||
.raw-results { margin-top:12px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:var(--radius); padding:8px 12px; font-size:12px; }
|
||||
.raw-results summary { cursor:pointer; color:var(--accent); font-family:var(--font-mono); }
|
||||
.raw-results ul { margin:8px 0 0 0; padding-left:20px; list-style:none; }
|
||||
.raw-results li { margin-bottom:8px; }
|
||||
.raw-results a { color:var(--accent); text-decoration:none; }
|
||||
.raw-results a:hover { text-decoration:underline; }
|
||||
.raw-results small { color:var(--text-muted); display:block; margin-top:2px; }
|
||||
|
||||
@media (max-width:768px) {
|
||||
.sidebar { display:none; }
|
||||
.topbar { padding:10px 14px; }
|
||||
.chat-container { padding:12px; }
|
||||
.input-area { padding:10px 12px; }
|
||||
}
|
||||
|
||||
.auth-screen { position: fixed; inset: 0; width: 100%; height: 100vh; display: none; align-items: center; justify-content: center; background: rgba(0,0,0,0.62); z-index: 3000; }
|
||||
.auth-card { width: 100%; max-width: 360px; margin: 0 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 12px; padding: 22px; box-shadow: 0 10px 28px rgba(0,0,0,0.35); }
|
||||
.auth-title { font-family: var(--font-mono); font-size: 18px; color: var(--accent); margin-bottom: 6px; }
|
||||
.auth-subtitle { font-size: 12px; color: var(--text-muted); margin-bottom: 14px; }
|
||||
.auth-warning { margin-bottom: 12px; font-size: 12px; color: #ff8f8f; background: rgba(231,76,60,0.14); border: 1px solid rgba(231,76,60,0.35); border-radius: var(--radius); padding: 8px 10px; line-height: 1.4; }
|
||||
.pin-input { width: 100%; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); font-family: var(--font-mono); font-size: 22px; letter-spacing: 6px; text-align: center; padding: 12px; margin-bottom: 10px; }
|
||||
.pin-input:focus { outline: none; border-color: var(--accent-dim); }
|
||||
.auth-btn { width: 100%; padding: 11px 14px; background: var(--accent-dim); border: none; border-radius: var(--radius); color: #fff; font-family: var(--font-mono); font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.auth-btn:hover { background: var(--accent); }
|
||||
.auth-error { min-height: 18px; margin-top: 10px; font-size: 12px; color: var(--danger); text-align: center; }
|
||||
.logout-btn { padding: 8px 10px; background: transparent; border: 1px solid var(--danger); border-radius: var(--radius); color: var(--danger); font-family: var(--font-mono); font-size: 11px; cursor: pointer; }
|
||||
.logout-btn:hover { background: rgba(231,76,60,0.12); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="auth-screen" id="authScreen">
|
||||
<div class="auth-card">
|
||||
<div class="auth-title">JarvisChat Unlock</div>
|
||||
<div class="auth-subtitle">Enter 4-digit admin PIN to unlock advanced actions</div>
|
||||
<div class="auth-warning">Security warning: PIN 1234 is weak. Use a non-trivial 4-digit PIN.</div>
|
||||
<input id="pinInput" class="pin-input" type="password" inputmode="numeric" maxlength="4" autocomplete="off" />
|
||||
<button id="unlockBtn" class="auth-btn" onclick="unlockWithPin()">UNLOCK</button>
|
||||
<div id="authError" class="auth-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="appShell" style="display:flex; width:100%; height:100%;">
|
||||
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img class="logo" src="/static/logo.jpg" alt="JarvisChat Logo" onerror="this.style.display='none'">
|
||||
@@ -238,7 +283,7 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
</div>
|
||||
<div class="modal-section">
|
||||
<h3>Web Search (SearXNG)</h3>
|
||||
<p class="desc">When enabled, JarvisChat will automatically search the web if the model indicates uncertainty.</p>
|
||||
<p class="desc">When enabled, JarvisChat will automatically search the web if the model indicates uncertainty. Use the 🔍 button to force a web search.</p>
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">Enable automatic web search</span>
|
||||
<div class="toggle-switch on" id="searchToggle" onclick="toggleSearch()"></div>
|
||||
@@ -251,6 +296,16 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
<button class="btn-small btn-save" onclick="addPreset()">+ Add Preset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-section">
|
||||
<h3>Skills (Phase 1)</h3>
|
||||
<p class="desc">Toggle built-in local skills used for tool-aware prompt injection. Master toggle disables all skills globally.</p>
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">Enable skills framework</span>
|
||||
<div class="toggle-switch on" id="skillsMasterToggle" onclick="toggleSkillsMaster()"></div>
|
||||
</div>
|
||||
<div class="skills-status" id="skillsStatus">Loading skills...</div>
|
||||
<div id="skillsList"></div>
|
||||
</div>
|
||||
<div class="modal-section">
|
||||
<h3>General</h3>
|
||||
<div class="toggle-row">
|
||||
@@ -272,12 +327,13 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
<button class="memory-badge on" id="memoryBadge" onclick="toggleMemory()" title="Toggle memory injection">🧠 MEM ON</button>
|
||||
<button class="search-badge on" id="searchBadge" onclick="toggleSearch()" title="Toggle auto web search">🔍 SEARCH ON</button>
|
||||
<button class="profile-badge on" id="profileBadge" onclick="toggleProfile()" title="Toggle profile injection">PROFILE ON</button>
|
||||
<button class="logout-btn" id="authActionBtn" onclick="handleAuthAction()" title="Unlock admin">ADMIN</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-container" id="chatContainer">
|
||||
<div class="welcome-screen" id="welcomeScreen">
|
||||
<div class="logo">⚡</div>
|
||||
<p>JarvisChat — your local coding companion.<br>Profile + Memory context injected automatically.<br>Web search kicks in when the model is uncertain.<br>Say "remember that..." to teach me things.</p>
|
||||
<p>JarvisChat — your local coding companion.<br>Profile + Memory context injected automatically.<br>Web search kicks in when the model is uncertain.<br>Use 🔍 to force a web search.<br>Say "remember that..." to teach me things.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-area">
|
||||
@@ -291,11 +347,14 @@ body { font-family: var(--font-body); background: var(--bg-primary); color: var(
|
||||
<div class="thermometer-bar"><div class="thermometer-fill" id="thermometerFill" style="height:0%"></div></div>
|
||||
<div class="token-info" id="tokenInfo">-- / --</div>
|
||||
</div>
|
||||
<button class="search-btn" id="searchBtn" onclick="sendSearch()" title="Search the web">🔍</button>
|
||||
<button class="send-btn" id="sendBtn" onclick="sendMessage()">SEND</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentConvId = null;
|
||||
let isStreaming = false;
|
||||
@@ -303,14 +362,223 @@ let abortController = null;
|
||||
let profileEnabled = true;
|
||||
let searchEnabled = true;
|
||||
let memoryEnabled = true;
|
||||
let skillsEnabled = true;
|
||||
let presets = [];
|
||||
let skillsRegistry = [];
|
||||
let modelContextSize = 8192;
|
||||
let cachedProfile = '';
|
||||
let conversationHistory = [];
|
||||
let appInitialized = false;
|
||||
let heartbeatIntervalId = null;
|
||||
let currentRole = 'guest';
|
||||
|
||||
const SESSION_KEY = 'jc_session_id';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
document.getElementById('pinInput').addEventListener('keydown', e => { if (e.key === 'Enter') unlockWithPin(); });
|
||||
await bootstrapAuth();
|
||||
});
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
// Best-effort server-side revoke on tab close; session timeout is the fallback if beacon is dropped.
|
||||
const sid = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!sid) return;
|
||||
navigator.sendBeacon('/api/auth/logout', sid);
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
});
|
||||
|
||||
function showAuthScreen() {
|
||||
document.getElementById('authScreen').style.display = 'flex';
|
||||
document.getElementById('pinInput').value = '';
|
||||
document.getElementById('authError').textContent = '';
|
||||
document.getElementById('pinInput').focus();
|
||||
}
|
||||
|
||||
function hideAuthScreen() {
|
||||
document.getElementById('authScreen').style.display = 'none';
|
||||
}
|
||||
|
||||
function showMainScreen() {
|
||||
document.getElementById('appShell').style.display = 'flex';
|
||||
}
|
||||
|
||||
function applyRoleUI() {
|
||||
// Guest mode keeps chat available while hiding controls that mutate system state.
|
||||
const isAdmin = currentRole === 'admin';
|
||||
const authBtn = document.getElementById('authActionBtn');
|
||||
const settingsBtn = document.querySelector('.settings-btn');
|
||||
const deleteAllBtn = document.querySelector('.delete-all-btn');
|
||||
const memoryBadge = document.getElementById('memoryBadge');
|
||||
const searchBadge = document.getElementById('searchBadge');
|
||||
const profileBadge = document.getElementById('profileBadge');
|
||||
|
||||
authBtn.textContent = isAdmin ? 'LOGOUT' : 'ADMIN';
|
||||
authBtn.title = isAdmin ? 'Logout admin mode' : 'Unlock admin mode';
|
||||
|
||||
settingsBtn.style.display = isAdmin ? 'inline-block' : 'none';
|
||||
deleteAllBtn.style.display = isAdmin ? 'inline-block' : 'none';
|
||||
memoryBadge.style.display = isAdmin ? 'inline-block' : 'none';
|
||||
searchBadge.style.display = isAdmin ? 'inline-block' : 'none';
|
||||
profileBadge.style.display = isAdmin ? 'inline-block' : 'none';
|
||||
|
||||
if (!isAdmin) closeSettings();
|
||||
}
|
||||
|
||||
function requireAdminNotice() {
|
||||
// Reuse the same PIN modal as a capability escalation prompt.
|
||||
showAuthScreen();
|
||||
document.getElementById('authError').textContent = 'Admin PIN required for this action';
|
||||
}
|
||||
|
||||
function handleAuthExpired() {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
currentRole = 'guest';
|
||||
if (heartbeatIntervalId) {
|
||||
clearInterval(heartbeatIntervalId);
|
||||
heartbeatIntervalId = null;
|
||||
}
|
||||
bootstrapAuth();
|
||||
}
|
||||
|
||||
async function authFetch(url, options = {}) {
|
||||
const sid = sessionStorage.getItem(SESSION_KEY);
|
||||
const headers = { ...(options.headers || {}) };
|
||||
if (sid) headers['X-Session-ID'] = sid;
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
if (response.status === 401) {
|
||||
// Session missing/expired/revoked: return to bootstrap flow and recreate guest session.
|
||||
handleAuthExpired();
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
if (response.status === 403) {
|
||||
// Authenticated but insufficient capability (guest hitting admin action).
|
||||
requireAdminNotice();
|
||||
throw new Error('Admin PIN required');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function createGuestSession() {
|
||||
// Guest session is the default so conversational UX works without PIN friction.
|
||||
const resp = await fetch('/api/auth/guest', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.detail || 'Unable to create guest session');
|
||||
sessionStorage.setItem(SESSION_KEY, data.session_id);
|
||||
currentRole = data.role || 'guest';
|
||||
}
|
||||
|
||||
async function bootstrapAuth() {
|
||||
const sid = sessionStorage.getItem(SESSION_KEY);
|
||||
try {
|
||||
if (sid) {
|
||||
// Restore prior tab session when possible to avoid unnecessary re-prompts.
|
||||
const resp = await fetch('/api/auth/session', {
|
||||
headers: { 'X-Session-ID': sid }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.authenticated) {
|
||||
currentRole = data.role || 'guest';
|
||||
} else {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
await createGuestSession();
|
||||
}
|
||||
} else {
|
||||
await createGuestSession();
|
||||
}
|
||||
|
||||
showMainScreen();
|
||||
hideAuthScreen();
|
||||
applyRoleUI();
|
||||
startHeartbeat();
|
||||
await initializeMainApp();
|
||||
} catch (e) {
|
||||
showMainScreen();
|
||||
showAuthScreen();
|
||||
}
|
||||
}
|
||||
|
||||
async function unlockWithPin() {
|
||||
const pinInput = document.getElementById('pinInput');
|
||||
const authError = document.getElementById('authError');
|
||||
const unlockBtn = document.getElementById('unlockBtn');
|
||||
const pin = (pinInput.value || '').trim();
|
||||
|
||||
if (!/^\d{4}$/.test(pin)) {
|
||||
authError.textContent = 'PIN must be 4 digits';
|
||||
return;
|
||||
}
|
||||
|
||||
unlockBtn.disabled = true;
|
||||
authError.textContent = '';
|
||||
try {
|
||||
const resp = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pin })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
authError.textContent = data.detail || 'Login failed';
|
||||
unlockBtn.disabled = false;
|
||||
pinInput.select();
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(SESSION_KEY, data.session_id);
|
||||
// Admin session replaces guest session in this tab only.
|
||||
currentRole = data.role || 'admin';
|
||||
showMainScreen();
|
||||
hideAuthScreen();
|
||||
applyRoleUI();
|
||||
startHeartbeat();
|
||||
await initializeMainApp();
|
||||
} catch (e) {
|
||||
authError.textContent = 'Unable to reach auth endpoint';
|
||||
} finally {
|
||||
unlockBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutToGuest() {
|
||||
try {
|
||||
await authFetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch (e) {
|
||||
// Ignore; local client state cleanup still proceeds.
|
||||
}
|
||||
// Drop elevated session and immediately re-issue a guest token for continued chat use.
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
await createGuestSession();
|
||||
currentRole = 'guest';
|
||||
applyRoleUI();
|
||||
hideAuthScreen();
|
||||
}
|
||||
|
||||
function handleAuthAction() {
|
||||
if (currentRole === 'admin') {
|
||||
logoutToGuest();
|
||||
return;
|
||||
}
|
||||
showAuthScreen();
|
||||
}
|
||||
|
||||
async function sendHeartbeat() {
|
||||
try {
|
||||
await authFetch('/api/auth/heartbeat', { method: 'POST' });
|
||||
} catch (e) {
|
||||
// authFetch handles invalid session case.
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
if (heartbeatIntervalId) clearInterval(heartbeatIntervalId);
|
||||
heartbeatIntervalId = setInterval(sendHeartbeat, 30000);
|
||||
}
|
||||
|
||||
async function initializeMainApp() {
|
||||
if (appInitialized) return;
|
||||
appInitialized = true;
|
||||
await loadModels();
|
||||
await loadSettings();
|
||||
await loadSkills();
|
||||
await loadProfile();
|
||||
await loadPresets();
|
||||
await loadConversations();
|
||||
@@ -323,16 +591,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
setInterval(updateSystemStats, 2000);
|
||||
document.getElementById('userInput').addEventListener('input', updateTokenThermometer);
|
||||
updateTokenThermometer();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMemoryStats() {
|
||||
try {
|
||||
const resp = await fetch('/api/memories/stats');
|
||||
const resp = await authFetch('/api/memories/stats');
|
||||
const data = await resp.json();
|
||||
document.getElementById('memoryStats').textContent = `Total: ${data.total} memories`;
|
||||
document.getElementById('memoryStatus').innerHTML = `<span class="status-dot"></span> memory: ${data.total} entries`;
|
||||
|
||||
const listResp = await fetch('/api/memories?limit=20');
|
||||
const listResp = await authFetch('/api/memories?limit=20');
|
||||
const listData = await listResp.json();
|
||||
const container = document.getElementById('memoryList');
|
||||
container.innerHTML = '';
|
||||
@@ -346,14 +614,15 @@ async function loadMemoryStats() {
|
||||
}
|
||||
|
||||
async function deleteMemory(rowid) {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
if (!confirm('Delete this memory?')) return;
|
||||
await fetch(`/api/memories/${rowid}`, { method: 'DELETE' });
|
||||
await authFetch(`/api/memories/${rowid}`, { method: 'DELETE' });
|
||||
await loadMemoryStats();
|
||||
}
|
||||
|
||||
async function updateSystemStats() {
|
||||
try {
|
||||
const resp = await fetch('/api/stats');
|
||||
const resp = await authFetch('/api/stats');
|
||||
const data = await resp.json();
|
||||
document.getElementById('cpuFill').style.width = data.cpu_percent + '%';
|
||||
document.getElementById('cpuFill').className = 'stat-fill' + (data.cpu_percent >= 90 ? ' danger' : data.cpu_percent >= 70 ? ' warn' : '');
|
||||
@@ -372,7 +641,7 @@ async function updateSystemStats() {
|
||||
|
||||
async function checkOllamaStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/ps');
|
||||
const resp = await authFetch('/api/ps');
|
||||
const data = await resp.json();
|
||||
const el = document.getElementById('ollamaStatus');
|
||||
const models = data.models || [];
|
||||
@@ -384,7 +653,7 @@ async function checkOllamaStatus() {
|
||||
|
||||
async function checkSearchStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/search/status');
|
||||
const resp = await authFetch('/api/search/status');
|
||||
const data = await resp.json();
|
||||
document.getElementById('searchStatus').innerHTML = data.available ? '<span class="status-dot"></span> search: ready' : '<span class="status-dot warning"></span> search: unavailable';
|
||||
} catch(e) {
|
||||
@@ -394,7 +663,7 @@ async function checkSearchStatus() {
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
const resp = await fetch('/api/models');
|
||||
const resp = await authFetch('/api/models');
|
||||
const data = await resp.json();
|
||||
const select = document.getElementById('modelSelect');
|
||||
const settingSelect = document.getElementById('defaultModelSetting');
|
||||
@@ -413,7 +682,7 @@ async function fetchModelContextSize() {
|
||||
const model = document.getElementById('modelSelect').value;
|
||||
if (!model) return;
|
||||
try {
|
||||
const resp = await fetch('/api/show', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ name: model }) });
|
||||
const resp = await authFetch('/api/show', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ name: model }) });
|
||||
const data = await resp.json();
|
||||
if (data.model_info && data.model_info['context_length']) modelContextSize = data.model_info['context_length'];
|
||||
else if (data.parameters) { const match = data.parameters.match(/num_ctx\s+(\d+)/); if (match) modelContextSize = parseInt(match[1]); }
|
||||
@@ -423,14 +692,16 @@ async function fetchModelContextSize() {
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const resp = await fetch('/api/settings');
|
||||
const resp = await authFetch('/api/settings');
|
||||
const s = await resp.json();
|
||||
profileEnabled = s.profile_enabled !== 'false';
|
||||
searchEnabled = s.search_enabled !== 'false';
|
||||
memoryEnabled = s.memory_enabled !== 'false';
|
||||
skillsEnabled = s.skills_enabled !== 'false';
|
||||
updateProfileUI();
|
||||
updateSearchUI();
|
||||
updateMemoryUI();
|
||||
updateSkillsUI();
|
||||
if (s.default_model) {
|
||||
document.getElementById('modelSelect').value = s.default_model;
|
||||
document.getElementById('defaultModelSetting').value = s.default_model;
|
||||
@@ -439,17 +710,19 @@ async function loadSettings() {
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
await fetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ profile_enabled: profileEnabled ? 'true' : 'false', search_enabled: searchEnabled ? 'true' : 'false', memory_enabled: memoryEnabled ? 'true' : 'false' }) });
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
await authFetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ profile_enabled: profileEnabled ? 'true' : 'false', search_enabled: searchEnabled ? 'true' : 'false', memory_enabled: memoryEnabled ? 'true' : 'false', skills_enabled: skillsEnabled ? 'true' : 'false' }) });
|
||||
}
|
||||
|
||||
async function saveDefaultModel() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
const model = document.getElementById('defaultModelSetting').value;
|
||||
await fetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ default_model: model }) });
|
||||
await authFetch('/api/settings', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ default_model: model }) });
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const resp = await fetch('/api/profile');
|
||||
const resp = await authFetch('/api/profile');
|
||||
const data = await resp.json();
|
||||
cachedProfile = data.content || '';
|
||||
document.getElementById('profileEditor').value = cachedProfile;
|
||||
@@ -459,8 +732,9 @@ async function loadProfile() {
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
const content = document.getElementById('profileEditor').value;
|
||||
await fetch('/api/profile', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content: content }) });
|
||||
await authFetch('/api/profile', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ content: content }) });
|
||||
cachedProfile = content;
|
||||
updateTokenCount();
|
||||
const btn = document.getElementById('saveProfileBtn');
|
||||
@@ -469,18 +743,19 @@ async function saveProfile() {
|
||||
}
|
||||
|
||||
async function resetProfile() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
if (!confirm('Reset profile to default?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/profile/default');
|
||||
const resp = await authFetch('/api/profile/default');
|
||||
const data = await resp.json();
|
||||
document.getElementById('profileEditor').value = data.content;
|
||||
await saveProfile();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function toggleProfile() { profileEnabled = !profileEnabled; updateProfileUI(); saveSettings(); }
|
||||
function toggleSearch() { searchEnabled = !searchEnabled; updateSearchUI(); saveSettings(); }
|
||||
function toggleMemory() { memoryEnabled = !memoryEnabled; updateMemoryUI(); saveSettings(); }
|
||||
function toggleProfile() { if (currentRole !== 'admin') { requireAdminNotice(); return; } profileEnabled = !profileEnabled; updateProfileUI(); saveSettings(); }
|
||||
function toggleSearch() { if (currentRole !== 'admin') { requireAdminNotice(); return; } searchEnabled = !searchEnabled; updateSearchUI(); saveSettings(); }
|
||||
function toggleMemory() { if (currentRole !== 'admin') { requireAdminNotice(); return; } memoryEnabled = !memoryEnabled; updateMemoryUI(); saveSettings(); }
|
||||
|
||||
function updateProfileUI() {
|
||||
const badge = document.getElementById('profileBadge');
|
||||
@@ -506,6 +781,92 @@ function updateMemoryUI() {
|
||||
if (toggle) toggle.className = 'toggle-switch' + (memoryEnabled ? ' on' : '');
|
||||
}
|
||||
|
||||
function updateSkillsUI() {
|
||||
const master = document.getElementById('skillsMasterToggle');
|
||||
if (master) master.className = 'toggle-switch' + (skillsEnabled ? ' on' : '');
|
||||
}
|
||||
|
||||
async function loadSkills() {
|
||||
try {
|
||||
const resp = await authFetch('/api/skills');
|
||||
const data = await resp.json();
|
||||
skillsRegistry = data.skills || [];
|
||||
renderSkills();
|
||||
} catch(e) {
|
||||
const status = document.getElementById('skillsStatus');
|
||||
if (status) status.textContent = 'Unable to load skills';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkills() {
|
||||
const container = document.getElementById('skillsList');
|
||||
const status = document.getElementById('skillsStatus');
|
||||
if (!container || !status) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
if (!skillsRegistry.length) {
|
||||
status.textContent = 'No skills registered';
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledCount = skillsRegistry.filter(s => s.enabled).length;
|
||||
status.textContent = `${enabledCount}/${skillsRegistry.length} skills enabled${skillsEnabled ? '' : ' (master toggle OFF)'}`;
|
||||
|
||||
skillsRegistry.forEach(skill => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'skill-item' + (skillsEnabled ? '' : ' disabled');
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'skill-meta';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'skill-name';
|
||||
const risk = (skill.risk || 'low').toUpperCase();
|
||||
name.innerHTML = `${skill.name} <span class="skill-risk">${risk}</span>`;
|
||||
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'skill-desc';
|
||||
desc.textContent = `${skill.key} - ${skill.description || ''}`;
|
||||
|
||||
meta.appendChild(name);
|
||||
meta.appendChild(desc);
|
||||
|
||||
const toggle = document.createElement('div');
|
||||
const active = !!skill.enabled && skillsEnabled;
|
||||
toggle.className = 'toggle-switch' + (active ? ' on' : '');
|
||||
toggle.title = skillsEnabled ? `Toggle ${skill.name}` : 'Enable skills framework first';
|
||||
toggle.addEventListener('click', () => toggleSkill(skill.key));
|
||||
|
||||
row.appendChild(meta);
|
||||
row.appendChild(toggle);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleSkillsMaster() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
skillsEnabled = !skillsEnabled;
|
||||
updateSkillsUI();
|
||||
renderSkills();
|
||||
await saveSettings();
|
||||
}
|
||||
|
||||
async function toggleSkill(skillKey) {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
if (!skillsEnabled) return;
|
||||
const skill = skillsRegistry.find(s => s.key === skillKey);
|
||||
if (!skill) return;
|
||||
|
||||
const nextEnabled = !skill.enabled;
|
||||
await authFetch(`/api/skills/${encodeURIComponent(skillKey)}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ enabled: nextEnabled })
|
||||
});
|
||||
skill.enabled = nextEnabled;
|
||||
renderSkills();
|
||||
}
|
||||
|
||||
function updateTokenCount() {
|
||||
const text = document.getElementById('profileEditor').value;
|
||||
cachedProfile = text;
|
||||
@@ -541,7 +902,7 @@ document.getElementById('presetSelect').addEventListener('change', updateTokenTh
|
||||
|
||||
async function loadPresets() {
|
||||
try {
|
||||
const resp = await fetch('/api/presets');
|
||||
const resp = await authFetch('/api/presets');
|
||||
presets = await resp.json();
|
||||
renderPresetList();
|
||||
renderPresetSelect();
|
||||
@@ -568,28 +929,31 @@ function renderPresetSelect() {
|
||||
}
|
||||
|
||||
async function addPreset() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
const name = prompt('Preset name:');
|
||||
if (!name) return;
|
||||
const p = prompt('System prompt text:');
|
||||
if (!p) return;
|
||||
await fetch('/api/presets', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) });
|
||||
await authFetch('/api/presets', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) });
|
||||
await loadPresets();
|
||||
}
|
||||
|
||||
async function editPreset(id) {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
const preset = presets.find(p => p.id === id);
|
||||
if (!preset) return;
|
||||
const name = prompt('Preset name:', preset.name);
|
||||
if (!name) return;
|
||||
const p = prompt('System prompt:', preset.prompt);
|
||||
if (p === null) return;
|
||||
await fetch(`/api/presets/${id}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) });
|
||||
await authFetch(`/api/presets/${id}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name, prompt: p}) });
|
||||
await loadPresets();
|
||||
}
|
||||
|
||||
async function deletePreset(id) {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
if (!confirm('Delete this preset?')) return;
|
||||
await fetch(`/api/presets/${id}`, { method: 'DELETE' });
|
||||
await authFetch(`/api/presets/${id}`, { method: 'DELETE' });
|
||||
await loadPresets();
|
||||
}
|
||||
|
||||
@@ -600,20 +964,27 @@ function getSelectedPresetPrompt() {
|
||||
return p ? p.prompt : '';
|
||||
}
|
||||
|
||||
function openSettings() { document.getElementById('settingsModal').classList.add('visible'); loadProfile(); loadMemoryStats(); }
|
||||
function openSettings() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
document.getElementById('settingsModal').classList.add('visible');
|
||||
loadProfile();
|
||||
loadMemoryStats();
|
||||
loadSkills();
|
||||
}
|
||||
function closeSettings() { document.getElementById('settingsModal').classList.remove('visible'); }
|
||||
document.getElementById('settingsModal').addEventListener('click', e => { if (e.target.id === 'settingsModal') closeSettings(); });
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const resp = await fetch('/api/conversations');
|
||||
const resp = await authFetch('/api/conversations');
|
||||
const convs = await resp.json();
|
||||
const list = document.getElementById('convList');
|
||||
list.innerHTML = '';
|
||||
convs.forEach(c => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'conv-item' + (c.id === currentConvId ? ' active' : '');
|
||||
div.innerHTML = `<span class="conv-title" onclick="loadConversation('${c.id}')">${c.title}</span><span class="conv-delete" onclick="event.stopPropagation();deleteConversation('${c.id}')">×</span>`;
|
||||
const delBtn = currentRole === 'admin' ? `<span class="conv-delete" onclick="event.stopPropagation();deleteConversation('${c.id}')">×</span>` : '';
|
||||
div.innerHTML = `<span class="conv-title" onclick="loadConversation('${c.id}')">${c.title}</span>${delBtn}`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
} catch(e) {}
|
||||
@@ -621,7 +992,7 @@ async function loadConversations() {
|
||||
|
||||
async function loadConversation(convId) {
|
||||
try {
|
||||
const resp = await fetch(`/api/conversations/${convId}`);
|
||||
const resp = await authFetch(`/api/conversations/${convId}`);
|
||||
const data = await resp.json();
|
||||
currentConvId = convId;
|
||||
document.getElementById('modelSelect').value = data.conversation.model;
|
||||
@@ -637,15 +1008,17 @@ async function loadConversation(convId) {
|
||||
}
|
||||
|
||||
async function deleteConversation(convId) {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
if (!confirm('Delete this conversation?')) return;
|
||||
await fetch(`/api/conversations/${convId}`, { method: 'DELETE' });
|
||||
await authFetch(`/api/conversations/${convId}`, { method: 'DELETE' });
|
||||
if (currentConvId === convId) { currentConvId = null; showWelcome(); }
|
||||
await loadConversations();
|
||||
}
|
||||
|
||||
async function deleteAllConversations() {
|
||||
if (currentRole !== 'admin') { requireAdminNotice(); return; }
|
||||
if (!confirm('Delete ALL conversations? This cannot be undone.')) return;
|
||||
await fetch('/api/conversations', { method: 'DELETE' });
|
||||
await authFetch('/api/conversations', { method: 'DELETE' });
|
||||
currentConvId = null;
|
||||
conversationHistory = [];
|
||||
showWelcome();
|
||||
@@ -662,7 +1035,80 @@ function newChat() {
|
||||
}
|
||||
|
||||
function showWelcome() {
|
||||
document.getElementById('chatContainer').innerHTML = '<div class="welcome-screen" id="welcomeScreen"><div class="logo">⚡</div><p>JarvisChat — your local coding companion.<br>Profile + Memory context injected automatically.<br>Web search kicks in when the model is uncertain.<br>Say "remember that..." to teach me things.</p></div>';
|
||||
document.getElementById('chatContainer').innerHTML = '<div class="welcome-screen" id="welcomeScreen"><div class="logo">⚡</div><p>JarvisChat — your local coding companion.<br>Profile + Memory context injected automatically.<br>Web search kicks in when the model is uncertain.<br>Use 🔍 to force a web search.<br>Say "remember that..." to teach me things.</p></div>';
|
||||
}
|
||||
|
||||
async function sendSearch() {
|
||||
const input = document.getElementById('userInput');
|
||||
const query = input.value.trim();
|
||||
if (!query || isStreaming) return;
|
||||
const model = document.getElementById('modelSelect').value;
|
||||
const welcome = document.getElementById('welcomeScreen');
|
||||
if (welcome) welcome.remove();
|
||||
appendMessage('user', '🔍 ' + query, true);
|
||||
conversationHistory.push({ role: 'user', content: '🔍 ' + query });
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
updateTokenThermometer();
|
||||
const assistantDiv = appendMessage('assistant', '', true, true);
|
||||
const textEl = assistantDiv.querySelector('.text');
|
||||
textEl.innerHTML = '<div class="search-indicator"><div class="spinner"></div>Searching the web...</div>';
|
||||
setStreamingState(true);
|
||||
try {
|
||||
abortController = new AbortController();
|
||||
const resp = await authFetch('/api/search', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId, query, model }), signal: abortController.signal });
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = '';
|
||||
let buffer = '';
|
||||
let firstToken = true;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.error) { textEl.textContent = 'Error: ' + data.error; setStreamingState(false); return; }
|
||||
if (data.conversation_id && !currentConvId) { currentConvId = data.conversation_id; await loadConversations(); }
|
||||
if (data.search_results) { textEl.innerHTML = '<div class="search-indicator">🔍 Found ' + data.search_results + ' results, summarizing...</div>'; }
|
||||
if (data.token) { if (firstToken) { textEl.innerHTML = ''; firstToken = false; } fullText += data.token; textEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }
|
||||
if (data.raw_results) {
|
||||
let rawHtml = '<details class="raw-results"><summary>🔍 View raw search results (' + data.raw_results.length + ')</summary><ul>';
|
||||
data.raw_results.forEach(r => {
|
||||
const safeUrl = sanitizeUrl(r.url || '');
|
||||
rawHtml += '<li>';
|
||||
if (safeUrl) {
|
||||
rawHtml += `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">${escapeHtml(r.title)}</a>`;
|
||||
} else {
|
||||
rawHtml += `<span>${escapeHtml(r.title)}</span>`;
|
||||
}
|
||||
if (r.content) rawHtml += `<small>${escapeHtml(r.content)}</small>`;
|
||||
rawHtml += '</li>';
|
||||
});
|
||||
rawHtml += '</ul></details>';
|
||||
textEl.innerHTML += rawHtml;
|
||||
}
|
||||
if (data.done) {
|
||||
const roleLabel = assistantDiv.querySelector('.role-label');
|
||||
if (roleLabel) roleLabel.innerHTML += '<span class="search-badge-inline">🔍 web</span>';
|
||||
conversationHistory.push({ role: 'assistant', content: fullText });
|
||||
updateTokenThermometer();
|
||||
addCopyButtons(assistantDiv);
|
||||
setStreamingState(false);
|
||||
await loadConversations();
|
||||
}
|
||||
} catch(e) { console.log('Parse error:', e); }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') textEl.innerHTML += '<br><em style="color:var(--text-muted)">[stopped]</em>';
|
||||
else textEl.textContent = 'Error: ' + e.message;
|
||||
setStreamingState(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
@@ -685,7 +1131,7 @@ async function sendMessage() {
|
||||
let searchTriggered = false;
|
||||
try {
|
||||
abortController = new AbortController();
|
||||
const resp = await fetch('/api/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId, message, model, system_prompt: presetPrompt }), signal: abortController.signal });
|
||||
const resp = await authFetch('/api/chat', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ conversation_id: currentConvId, message, model, system_prompt: presetPrompt }), signal: abortController.signal });
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = '';
|
||||
@@ -731,15 +1177,25 @@ async function sendMessage() {
|
||||
|
||||
function setStreamingState(streaming) {
|
||||
isStreaming = streaming;
|
||||
const btn = document.getElementById('sendBtn');
|
||||
if (streaming) { btn.textContent = 'STOP'; btn.className = 'stop-btn'; btn.onclick = () => { if (abortController) abortController.abort(); setStreamingState(false); }; }
|
||||
else { btn.textContent = 'SEND'; btn.className = 'send-btn'; btn.onclick = sendMessage; }
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
if (streaming) {
|
||||
sendBtn.textContent = 'STOP';
|
||||
sendBtn.className = 'stop-btn';
|
||||
sendBtn.onclick = () => { if (abortController) abortController.abort(); setStreamingState(false); };
|
||||
searchBtn.disabled = true;
|
||||
} else {
|
||||
sendBtn.textContent = 'SEND';
|
||||
sendBtn.className = 'send-btn';
|
||||
sendBtn.onclick = sendMessage;
|
||||
searchBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessage(role, content, animate) {
|
||||
function appendMessage(role, content, animate, isSearch = false) {
|
||||
const container = document.getElementById('chatContainer');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message ' + role;
|
||||
div.className = 'message ' + role + (isSearch && role === 'assistant' ? ' search-result' : '');
|
||||
if (!animate) div.style.animation = 'none';
|
||||
div.innerHTML = `<div class="avatar">${role === 'user' ? 'YOU' : 'AI'}</div><div class="content"><div class="role-label">${role}</div><div class="text">${content ? renderMarkdown(content) : ''}</div></div>`;
|
||||
container.appendChild(div);
|
||||
@@ -761,6 +1217,17 @@ function renderMarkdown(text) {
|
||||
return h;
|
||||
}
|
||||
|
||||
function sanitizeUrl(url) {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.href;
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function addCopyButtons(msgDiv) {
|
||||
msgDiv.querySelectorAll('pre').forEach(pre => {
|
||||
if (pre.querySelector('.copy-btn')) return;
|
||||
|
||||
78
tests/test_auth_capabilities.py
Normal file
78
tests/test_auth_capabilities.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
def make_client(tmp_path: Path) -> TestClient:
|
||||
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
|
||||
app_module.DB_PATH = tmp_path / "jarvischat-test.db"
|
||||
app_module.SESSIONS.clear()
|
||||
app_module.PIN_ATTEMPTS.clear()
|
||||
app_module.init_db()
|
||||
return TestClient(app_module.app)
|
||||
|
||||
|
||||
def test_guest_read_only_admin_write_blocked(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
guest = client.post("/api/auth/guest", headers={"Origin": "http://testserver"})
|
||||
assert guest.status_code == 200
|
||||
sid = guest.json()["session_id"]
|
||||
headers = {"X-Session-ID": sid}
|
||||
|
||||
read_resp = client.get("/api/memories", headers=headers)
|
||||
assert read_resp.status_code == 200
|
||||
|
||||
write_resp = client.post(
|
||||
"/api/memories",
|
||||
json={"fact": "guest write should fail", "topic": "general"},
|
||||
headers={**headers, "Origin": "http://testserver"},
|
||||
)
|
||||
assert write_resp.status_code == 403
|
||||
|
||||
|
||||
def test_admin_can_write_and_delete_memory(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"pin": "1234"},
|
||||
headers={"Origin": "http://testserver"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
sid = login.json()["session_id"]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
|
||||
create_resp = client.post(
|
||||
"/api/memories",
|
||||
json={"fact": "admin write ok", "topic": "general"},
|
||||
headers=headers,
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
rowid = create_resp.json()["rowid"]
|
||||
|
||||
delete_resp = client.delete(f"/api/memories/{rowid}", headers=headers)
|
||||
assert delete_resp.status_code == 200
|
||||
|
||||
|
||||
def test_origin_check_blocks_cross_site_writes(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
denied = client.post("/api/auth/guest", headers={"Origin": "http://evil.example"})
|
||||
assert denied.status_code == 403
|
||||
|
||||
allowed = client.post("/api/auth/guest", headers={"Origin": "http://testserver"})
|
||||
assert allowed.status_code == 200
|
||||
|
||||
|
||||
def test_logout_revokes_session(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
guest = client.post("/api/auth/guest", headers={"Origin": "http://testserver"})
|
||||
sid = guest.json()["session_id"]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
|
||||
logout = client.post("/api/auth/logout", headers=headers)
|
||||
assert logout.status_code == 200
|
||||
|
||||
after = client.get("/api/memories", headers={"X-Session-ID": sid})
|
||||
assert after.status_code == 401
|
||||
188
tests/test_chat_streaming_and_memory_paths.py
Normal file
188
tests/test_chat_streaming_and_memory_paths.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
def make_client(tmp_path: Path) -> TestClient:
|
||||
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
|
||||
app_module.DB_PATH = tmp_path / "jarvischat-streaming.db"
|
||||
app_module.SESSIONS.clear()
|
||||
app_module.PIN_ATTEMPTS.clear()
|
||||
app_module.RATE_EVENTS.clear()
|
||||
app_module.init_db()
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def parse_sse_payloads(body: str) -> list[dict]:
|
||||
payloads: list[dict] = []
|
||||
for chunk in body.split("\n\n"):
|
||||
chunk = chunk.strip()
|
||||
if not chunk.startswith("data: "):
|
||||
continue
|
||||
raw = chunk[len("data: ") :]
|
||||
payloads.append(json.loads(raw))
|
||||
return payloads
|
||||
|
||||
|
||||
class _MockStreamResponse:
|
||||
def __init__(self, lines: list[str]):
|
||||
self._lines = lines
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def aiter_lines(self):
|
||||
for line in self._lines:
|
||||
yield line
|
||||
|
||||
|
||||
def _stream_json_lines(events: list[dict]) -> list[str]:
|
||||
return [json.dumps(event) for event in events]
|
||||
|
||||
|
||||
def test_chat_stream_emits_tokens_and_done(tmp_path: Path, monkeypatch):
|
||||
with make_client(tmp_path) as client:
|
||||
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
|
||||
"session_id"
|
||||
]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
|
||||
events = _stream_json_lines(
|
||||
[
|
||||
{"message": {"content": "Hel"}, "logprobs": [{"logprob": -0.01}]},
|
||||
{"message": {"content": "lo"}, "logprobs": [{"logprob": -0.01}]},
|
||||
{"done": True, "eval_count": 2, "eval_duration": 1000000000},
|
||||
]
|
||||
)
|
||||
|
||||
def stream_stub(self, method, url, json=None, timeout=None):
|
||||
return _MockStreamResponse(events)
|
||||
|
||||
monkeypatch.setattr(app_module.httpx.AsyncClient, "stream", stream_stub)
|
||||
|
||||
resp = client.post(
|
||||
"/api/chat",
|
||||
json={"message": "hello", "model": app_module.DEFAULT_MODEL},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
payloads = parse_sse_payloads(resp.text)
|
||||
|
||||
token_text = "".join(p.get("token", "") for p in payloads if "token" in p)
|
||||
assert token_text == "Hello"
|
||||
done_events = [p for p in payloads if p.get("done")]
|
||||
assert done_events
|
||||
assert "searched" not in done_events[-1]
|
||||
|
||||
|
||||
def test_chat_auto_search_trigger_emits_search_events(tmp_path: Path, monkeypatch):
|
||||
with make_client(tmp_path) as client:
|
||||
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
|
||||
"session_id"
|
||||
]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
|
||||
first_stream = _stream_json_lines(
|
||||
[
|
||||
{
|
||||
"message": {"content": "I am uncertain."},
|
||||
"logprobs": [{"logprob": -5.0}],
|
||||
},
|
||||
{"done": True, "eval_count": 2, "eval_duration": 1000000000},
|
||||
]
|
||||
)
|
||||
second_stream = _stream_json_lines(
|
||||
[
|
||||
{"message": {"content": "Based on current data: 42."}},
|
||||
{"done": True},
|
||||
]
|
||||
)
|
||||
stream_batches = [first_stream, second_stream]
|
||||
|
||||
def stream_stub(self, method, url, json=None, timeout=None):
|
||||
return _MockStreamResponse(stream_batches.pop(0))
|
||||
|
||||
async def search_stub(query: str, max_results: int = 5):
|
||||
return [
|
||||
{
|
||||
"title": "Answer",
|
||||
"url": "https://example.com",
|
||||
"content": "The value is 42.",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(app_module.httpx.AsyncClient, "stream", stream_stub)
|
||||
monkeypatch.setattr(app_module, "query_searxng", search_stub)
|
||||
|
||||
resp = client.post(
|
||||
"/api/chat",
|
||||
json={"message": "what is the latest value", "model": app_module.DEFAULT_MODEL},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
payloads = parse_sse_payloads(resp.text)
|
||||
|
||||
assert any(p.get("searching") is True for p in payloads)
|
||||
assert any("search_results" in p for p in payloads)
|
||||
assert any(p.get("augmented") is True for p in payloads)
|
||||
done_events = [p for p in payloads if p.get("done")]
|
||||
assert done_events and done_events[-1].get("searched") is True
|
||||
|
||||
|
||||
def test_memory_command_paths_remember_and_forget(tmp_path: Path, monkeypatch):
|
||||
with make_client(tmp_path) as client:
|
||||
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
|
||||
"session_id"
|
||||
]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
|
||||
base_stream = _stream_json_lines(
|
||||
[
|
||||
{"message": {"content": "ok"}, "logprobs": [{"logprob": -0.01}]},
|
||||
{"done": True, "eval_count": 1, "eval_duration": 1000000000},
|
||||
]
|
||||
)
|
||||
|
||||
def stream_stub(self, method, url, json=None, timeout=None):
|
||||
return _MockStreamResponse(base_stream)
|
||||
|
||||
monkeypatch.setattr(app_module.httpx.AsyncClient, "stream", stream_stub)
|
||||
|
||||
remember_resp = client.post(
|
||||
"/api/chat",
|
||||
json={
|
||||
"message": "remember that my favorite language is rust",
|
||||
"model": app_module.DEFAULT_MODEL,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert remember_resp.status_code == 200
|
||||
remember_events = parse_sse_payloads(remember_resp.text)
|
||||
assert any("Remembered" in p.get("token", "") for p in remember_events)
|
||||
|
||||
memories_after_add = client.get("/api/memories", headers={"X-Session-ID": sid})
|
||||
assert memories_after_add.status_code == 200
|
||||
assert memories_after_add.json().get("count", 0) >= 1
|
||||
|
||||
forget_resp = client.post(
|
||||
"/api/chat",
|
||||
json={
|
||||
"message": "forget about my favorite language",
|
||||
"model": app_module.DEFAULT_MODEL,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert forget_resp.status_code == 200
|
||||
forget_events = parse_sse_payloads(forget_resp.text)
|
||||
assert any("Forgot" in p.get("token", "") for p in forget_events)
|
||||
|
||||
memories_after_forget = client.get("/api/memories", headers={"X-Session-ID": sid})
|
||||
assert memories_after_forget.status_code == 200
|
||||
assert memories_after_forget.json().get("count", 0) == 0
|
||||
72
tests/test_error_envelopes.py
Normal file
72
tests/test_error_envelopes.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
def make_client(tmp_path: Path) -> TestClient:
|
||||
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
|
||||
app_module.DB_PATH = tmp_path / "jarvischat-errors.db"
|
||||
app_module.SESSIONS.clear()
|
||||
app_module.PIN_ATTEMPTS.clear()
|
||||
app_module.RATE_EVENTS.clear()
|
||||
app_module.init_db()
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_unhandled_api_exception_returns_friendly_error_with_incident_key(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
with make_client(tmp_path) as client:
|
||||
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
|
||||
"session_id"
|
||||
]
|
||||
headers = {"X-Session-ID": sid}
|
||||
|
||||
def boom(_topic=None):
|
||||
raise RuntimeError("super secret db internals")
|
||||
|
||||
monkeypatch.setattr(app_module, "get_all_memories", boom)
|
||||
|
||||
resp = client.get("/api/memories", headers=headers)
|
||||
assert resp.status_code == 500
|
||||
payload = resp.json()
|
||||
assert payload.get("error_key", "").startswith("INC-")
|
||||
assert "support lookup" in payload.get("detail", "").lower()
|
||||
assert "super secret db internals" not in resp.text
|
||||
|
||||
|
||||
def test_chat_stream_error_hides_internal_exception_and_emits_incident_key(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
with make_client(tmp_path) as client:
|
||||
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
|
||||
"session_id"
|
||||
]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
|
||||
class BrokenStreamContext:
|
||||
async def __aenter__(self):
|
||||
raise RuntimeError("ultra secret model transport failure")
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def broken_stream(*args, **kwargs):
|
||||
return BrokenStreamContext()
|
||||
|
||||
monkeypatch.setattr(app_module.httpx.AsyncClient, "stream", broken_stream)
|
||||
|
||||
resp = client.post(
|
||||
"/api/chat",
|
||||
json={"message": "hello", "model": app_module.DEFAULT_MODEL},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "ultra secret model transport failure" not in body
|
||||
assert "error_key" in body
|
||||
assert "support lookup" in body.lower()
|
||||
50
tests/test_ip_allowlist.py
Normal file
50
tests/test_ip_allowlist.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
def make_client(tmp_path: Path) -> TestClient:
|
||||
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
|
||||
app_module.DB_PATH = tmp_path / "jarvischat-ip.db"
|
||||
app_module.SESSIONS.clear()
|
||||
app_module.PIN_ATTEMPTS.clear()
|
||||
app_module.RATE_EVENTS.clear()
|
||||
app_module.init_db()
|
||||
return TestClient(app_module.app)
|
||||
|
||||
|
||||
def test_ip_helper_allows_local_defaults():
|
||||
assert app_module.is_ip_allowed("127.0.0.1")
|
||||
assert app_module.is_ip_allowed("192.168.1.10")
|
||||
assert app_module.is_ip_allowed("10.0.0.42")
|
||||
assert app_module.is_ip_allowed("172.16.1.2")
|
||||
assert app_module.is_ip_allowed("testclient")
|
||||
|
||||
|
||||
def test_ip_helper_blocks_public_ip():
|
||||
assert not app_module.is_ip_allowed("8.8.8.8")
|
||||
|
||||
|
||||
def test_middleware_blocks_disallowed_ip(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
original_get_client_ip = app_module.get_client_ip
|
||||
try:
|
||||
app_module.get_client_ip = lambda _req: "8.8.8.8"
|
||||
resp = client.post("/api/auth/guest")
|
||||
assert resp.status_code == 403
|
||||
finally:
|
||||
app_module.get_client_ip = original_get_client_ip
|
||||
|
||||
|
||||
def test_middleware_allows_local_ip(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
original_get_client_ip = app_module.get_client_ip
|
||||
try:
|
||||
app_module.get_client_ip = lambda _req: "192.168.50.109"
|
||||
resp = client.post("/api/auth/guest")
|
||||
assert resp.status_code == 200
|
||||
finally:
|
||||
app_module.get_client_ip = original_get_client_ip
|
||||
76
tests/test_rate_and_payload_guardrails.py
Normal file
76
tests/test_rate_and_payload_guardrails.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
def make_client(tmp_path: Path) -> TestClient:
|
||||
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
|
||||
app_module.DB_PATH = tmp_path / "jarvischat-rate.db"
|
||||
app_module.SESSIONS.clear()
|
||||
app_module.PIN_ATTEMPTS.clear()
|
||||
app_module.RATE_EVENTS.clear()
|
||||
app_module.init_db()
|
||||
return TestClient(app_module.app)
|
||||
|
||||
|
||||
def test_stats_rate_limit_hits_429(tmp_path: Path):
|
||||
old_limit = app_module.RL_STATS_PER_WINDOW
|
||||
old_window = app_module.RATE_WINDOW_SECONDS
|
||||
app_module.RL_STATS_PER_WINDOW = 2
|
||||
app_module.RATE_WINDOW_SECONDS = 60
|
||||
try:
|
||||
with make_client(tmp_path) as client:
|
||||
sid = client.post("/api/auth/guest").json()["session_id"]
|
||||
headers = {"X-Session-ID": sid}
|
||||
|
||||
r1 = client.get("/api/stats", headers=headers)
|
||||
r2 = client.get("/api/stats", headers=headers)
|
||||
r3 = client.get("/api/stats", headers=headers)
|
||||
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert r3.status_code == 429
|
||||
finally:
|
||||
app_module.RL_STATS_PER_WINDOW = old_limit
|
||||
app_module.RATE_WINDOW_SECONDS = old_window
|
||||
|
||||
|
||||
def test_large_login_payload_rejected_413(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
huge_pin = "1" * (app_module.BODY_LIMIT_DEFAULT_BYTES + 100)
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
data=json.dumps({"pin": huge_pin}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
def test_chat_message_length_rejected_413(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
sid = client.post("/api/auth/guest").json()["session_id"]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
message = "x" * (app_module.MAX_CHAT_MESSAGE_CHARS + 1)
|
||||
resp = client.post(
|
||||
"/api/chat",
|
||||
json={"message": message, "model": app_module.DEFAULT_MODEL},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
|
||||
|
||||
def test_search_query_length_rejected_413(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
sid = client.post("/api/auth/guest").json()["session_id"]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
query = "q" * (app_module.MAX_SEARCH_QUERY_CHARS + 1)
|
||||
resp = client.post(
|
||||
"/api/search",
|
||||
json={"query": query, "model": app_module.DEFAULT_MODEL},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 413
|
||||
17
tests/test_search_url_sanitization.py
Normal file
17
tests/test_search_url_sanitization.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import app as app_module
|
||||
|
||||
|
||||
def test_sanitize_outbound_url_allows_http_https():
|
||||
assert app_module.sanitize_outbound_url("https://example.com/path") == "https://example.com/path"
|
||||
assert app_module.sanitize_outbound_url("http://example.com") == "http://example.com"
|
||||
|
||||
|
||||
def test_sanitize_outbound_url_blocks_unsafe_schemes():
|
||||
assert app_module.sanitize_outbound_url("javascript:alert(1)") == ""
|
||||
assert app_module.sanitize_outbound_url("data:text/html,evil") == ""
|
||||
assert app_module.sanitize_outbound_url("file:///etc/passwd") == ""
|
||||
|
||||
|
||||
def test_sanitize_outbound_url_blocks_relative_and_empty():
|
||||
assert app_module.sanitize_outbound_url("/relative/path") == ""
|
||||
assert app_module.sanitize_outbound_url("") == ""
|
||||
57
tests/test_settings_allowlist.py
Normal file
57
tests/test_settings_allowlist.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
def make_admin_client(tmp_path: Path) -> tuple[TestClient, dict[str, str]]:
|
||||
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
|
||||
app_module.DB_PATH = tmp_path / "jarvischat-settings.db"
|
||||
app_module.SESSIONS.clear()
|
||||
app_module.PIN_ATTEMPTS.clear()
|
||||
app_module.init_db()
|
||||
|
||||
client = TestClient(app_module.app)
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"pin": "1234"},
|
||||
headers={"Origin": "http://testserver"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
sid = login.json()["session_id"]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
return client, headers
|
||||
|
||||
|
||||
def test_settings_allow_known_keys(tmp_path: Path):
|
||||
client, headers = make_admin_client(tmp_path)
|
||||
try:
|
||||
resp = client.put(
|
||||
"/api/settings",
|
||||
json={
|
||||
"profile_enabled": "false",
|
||||
"search_enabled": "true",
|
||||
"memory_enabled": "false",
|
||||
"default_model": "llama3.1:latest",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_settings_reject_unknown_keys(tmp_path: Path):
|
||||
client, headers = make_admin_client(tmp_path)
|
||||
try:
|
||||
resp = client.put(
|
||||
"/api/settings",
|
||||
json={"admin_pin_hash": "oops"},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Unknown setting key" in resp.json().get("detail", "")
|
||||
finally:
|
||||
client.close()
|
||||
93
tests/test_skills_framework.py
Normal file
93
tests/test_skills_framework.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
def make_client(tmp_path: Path) -> TestClient:
|
||||
os.environ["JARVISCHAT_ADMIN_PIN"] = "1234"
|
||||
app_module.DB_PATH = tmp_path / "jarvischat-skills.db"
|
||||
app_module.SESSIONS.clear()
|
||||
app_module.PIN_ATTEMPTS.clear()
|
||||
app_module.RATE_EVENTS.clear()
|
||||
app_module.init_db()
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_guest_can_list_skills(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
sid = client.post("/api/auth/guest", headers={"Origin": "http://testserver"}).json()[
|
||||
"session_id"
|
||||
]
|
||||
resp = client.get("/api/skills", headers={"X-Session-ID": sid})
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert payload["count"] >= 1
|
||||
assert any(skill["key"] == "memory.search" for skill in payload["skills"])
|
||||
|
||||
|
||||
def test_admin_can_toggle_skill_enabled_state(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"pin": "1234"},
|
||||
headers={"Origin": "http://testserver"},
|
||||
)
|
||||
sid = login.json()["session_id"]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
|
||||
disable = client.put(
|
||||
"/api/skills/search.web",
|
||||
json={"enabled": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert disable.status_code == 200
|
||||
assert disable.json()["skill"]["enabled"] is False
|
||||
|
||||
active = client.get("/api/skills/active", headers={"X-Session-ID": sid})
|
||||
assert active.status_code == 200
|
||||
assert all(skill["key"] != "search.web" for skill in active.json()["skills"])
|
||||
|
||||
|
||||
def test_unknown_skill_update_is_rejected(tmp_path: Path):
|
||||
with make_client(tmp_path) as client:
|
||||
login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"pin": "1234"},
|
||||
headers={"Origin": "http://testserver"},
|
||||
)
|
||||
sid = login.json()["session_id"]
|
||||
headers = {"X-Session-ID": sid, "Origin": "http://testserver"}
|
||||
|
||||
resp = client.put(
|
||||
"/api/skills/nope.unknown",
|
||||
json={"enabled": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_prompt_injection_respects_skills_enabled_setting(tmp_path: Path):
|
||||
with make_client(tmp_path):
|
||||
db = app_module.get_db()
|
||||
try:
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||
("skills_enabled", "false"),
|
||||
)
|
||||
db.commit()
|
||||
without_skills = app_module.build_system_prompt(db, "", "hello")
|
||||
assert "## Active Skills" not in without_skills
|
||||
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||
("skills_enabled", "true"),
|
||||
)
|
||||
db.commit()
|
||||
with_skills = app_module.build_system_prompt(db, "", "hello")
|
||||
assert "## Active Skills" in with_skills
|
||||
assert "memory.search" in with_skills
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user