Compare commits

..

26 Commits

Author SHA1 Message Date
18bca027de docs: replace README screenshot asset (v1.7.8) 2026-04-28 09:14:54 -07:00
36bca94840 docs(todo): add model/preset preflight validation item (v1.7.7) 2026-04-28 09:08:36 -07:00
71b48d940f docs: add v1.6/v1.7 release notes and developer wiki (v1.7.6) 2026-04-28 08:53:54 -07:00
58945a4324 feat(ui): add phase-1 skills toggles in settings (v1.7.5) 2026-04-28 08:49:19 -07:00
4d1541412b feat(skills): add phase-1 skill registry and toggles (v1.7.4) 2026-04-28 08:44:22 -07:00
250fec1f06 test(streaming): cover chat/search/memory paths (v1.7.3) 2026-04-28 08:31:01 -07:00
12188f3ad2 feat(errors): incident-key safe error envelopes (v1.7.2) 2026-04-27 16:56:17 -07:00
9589141521 feat(settings): allowlist /api/settings keys (v1.7.1) 2026-04-27 16:48:19 -07:00
c88e52e0ef chore(release): bump version to v1.7.0 2026-04-27 16:44:33 -07:00
76e4461b38 feat(security): add LAN IP allowlist and ingress guardrails 2026-04-27 16:43:21 -07:00
28aa40c42a release: v1.6.1 link sanitization and backlog updates 2026-04-27 16:25:35 -07:00
d9eba53926 fix(memory): sanitize FTS query tokens to handle punctuation 2026-04-27 10:23:42 -07:00
091a851064 chore(release): bump version to v1.6.0 2026-04-27 10:14:24 -07:00
81319f83d4 feat(auth): add guest/admin PIN security model and hardening 2026-04-27 10:09:53 -07:00
fc11b73319 Update readme.md
marked #1 as completed
2026-04-08 05:02:30 +00:00
46f1d6bf4e Add CLAUDE.md with architecture and development guidance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:12:39 -07:00
6f410e29d2 Fix type errors and bare except clauses in app.py; update readme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:09:13 -07:00
7a151b7d50 Remove unused imports and dead code; update readme
- Drop unused JSONResponse import from fastapi.responses
- Remove never-used raw_results_md variable in explicit_search stream
- Note cleanup in v1.5.0 changelog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:53:48 -07:00
6988997144 added readme for 1.5 2026-03-15 18:06:00 -07:00
c798f1220c updated readme with new todos, minor css tweak 2026-03-15 17:51:27 -07:00
dc55d0a8c9 add jarvischat logo 2.0 2026-03-15 17:47:23 -07:00
3d1ede26ca v.1.5.0: Explicit web search button, orange search styling 2026-03-15 17:12:20 -07:00
d57f009b10 Fix default model to llama3.1:latest 2026-03-15 15:57:33 -07:00
1c91c336a9 docs: update readme for v1.4.0, fix venv instructions 2026-03-15 15:27:35 -07:00
757f26669a stupid error fix for the logo 2026-03-15 14:56:47 -07:00
7fccb926db fix: logo extension jpg to png 2026-03-15 14:54:18 -07:00
18 changed files with 3322 additions and 200 deletions

74
CLAUDE.md Normal file
View 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`).

1566
app.py

File diff suppressed because it is too large Load Diff

View 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

View 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
View 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
View 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
View File

@@ -1,74 +1,304 @@
# ⚡ JarvisChat v1.4.0
# ⚡ JarvisChat v1.7.8
![screenshot](docs/images/screenshot.png)
**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

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@@ -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;

View 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

View 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

View 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()

View 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

View 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

View 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("") == ""

View 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()

View 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()