Compare commits
124 Commits
v26.03.16
...
v26.03.29.1
| Author | SHA1 | Date | |
|---|---|---|---|
| f8b7b51832 | |||
| 0cbeb6b7ac | |||
| 218f946e48 | |||
| 00643c778e | |||
| 20e2333b63 | |||
| 024075329d | |||
| 697d99cc4d | |||
| 164c55e845 | |||
| c77c13684f | |||
| 7b95150101 | |||
| 96a07690a8 | |||
| 6da4b098e3 | |||
| 7fdb2ee39d | |||
| 83cee46078 | |||
| 534e0a3a34 | |||
| 51d2fd9d0a | |||
| ac90548823 | |||
| 37e2420192 | |||
| f81c38df84 | |||
| 0a6e57e698 | |||
| 260103d533 | |||
| 35f57e0d3e | |||
| 57bf63e576 | |||
| aa18f4f527 | |||
| 3391825550 | |||
| 3d7c953627 | |||
| 6557fef6a2 | |||
| 0dc3dfa539 | |||
| 3179b60eac | |||
| a24257aeed | |||
| c271f1b41f | |||
| c92f4944cc | |||
| 1125c8e107 | |||
| 7888788d42 | |||
| 7a12cba4d5 | |||
| 78791175a1 | |||
| 2a1644e571 | |||
| bc5f1679d5 | |||
| 22634aa0c9 | |||
| 699e525cb9 | |||
| 2b2e5c666a | |||
| 26a8fb5c51 | |||
| 3431719ff3 | |||
| 916cfa50df | |||
| 190664366d | |||
| 08d738ddfb | |||
| a63e498067 | |||
| 48d1d9e64f | |||
| 538b67e57d | |||
| 383a4430f1 | |||
| 4aacd093e5 | |||
| aab478359b | |||
| 6214666942 | |||
| 62dbb8d496 | |||
| fd05c65018 | |||
| c8aa5834fa | |||
| 87c55691fb | |||
| ebc79b34f9 | |||
| 1e0d11c907 | |||
| 17db511119 | |||
| e0c3836fab | |||
| df1194ee96 | |||
| 0277f5744f | |||
| 809d8e0008 | |||
| c3e201d26a | |||
| 8d330afc6d | |||
| 90ca667df2 | |||
| b547f47f54 | |||
| 57f837984c | |||
| da55e32a1a | |||
| 651bc1ba7b | |||
| 0e27be5b63 | |||
| fe6afbad17 | |||
| e57ac26749 | |||
| 940dd0c08e | |||
| 5f6107bbf8 | |||
| 06cb7cc86d | |||
| a691fc043d | |||
| aa46551ccf | |||
| 0c2d9c2f6c | |||
| 359b5f0545 | |||
| dc93e0d39f | |||
| e3c1e97cfa | |||
| 3b71549b91 | |||
| 2ad07b5e06 | |||
| 9e1615bd32 | |||
| e44eb185d5 | |||
| 5665f484ed | |||
| 8fa850534c | |||
| fa200fd528 | |||
| 24bd80b5d7 | |||
| bf292e6019 | |||
| e2133529a0 | |||
| 47e248d9ac | |||
| 553c38200a | |||
| 6564a03c0e | |||
| 9cd0de3883 | |||
| 0db5dd126c | |||
| a2ba90160c | |||
| a9414cf949 | |||
| e407043713 | |||
| 00dee5ea0e | |||
| 55e260b392 | |||
| 359cb24d9a | |||
| 6180ee65c3 | |||
| 66571e16c1 | |||
| 73eb34d722 | |||
| 47b4af281b | |||
| e8a4ca915a | |||
| 583a140485 | |||
| 7358909432 | |||
| c24b21f259 | |||
| e54d172487 | |||
| f9973e22f1 | |||
| e9ddab7368 | |||
| 16b2b5c68e | |||
| ce5b2ffaeb | |||
| c232a7d079 | |||
| 1c3f0ab7f7 | |||
| 33f52081e5 | |||
| a58dbe79f2 | |||
| 0d41b8be34 | |||
| 46672725a1 | |||
| 4d55d9d82a |
@@ -2,7 +2,7 @@
|
||||
#
|
||||
# Push to dev: typecheck + lint + test → build :dev + :<sha>
|
||||
# Tag v* (release): typecheck + lint + test → build :latest + :<sha> + :<version>
|
||||
# Pull request: typecheck + lint + test only (no build)
|
||||
# Pull request: no CI run — code is validated on dev push before any PR is opened
|
||||
#
|
||||
# To cut a release:
|
||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||
@@ -26,14 +26,10 @@ on:
|
||||
- "alembic.ini"
|
||||
- "Dockerfile"
|
||||
- "assets/**"
|
||||
- "fable-mcp/**"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/**"
|
||||
- "frontend/**"
|
||||
- "tests/**"
|
||||
- "pyproject.toml"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
# pull_request trigger intentionally omitted — all changes go through dev
|
||||
# first, where CI already runs on push. PR runs would be redundant duplication.
|
||||
|
||||
env:
|
||||
REGISTRY: git.fabledsword.com
|
||||
@@ -142,5 +138,3 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
|
||||
|
||||
@@ -23,6 +23,8 @@ settings.local.json
|
||||
# Claude Code
|
||||
.claude/
|
||||
docs/superpowers/
|
||||
docs/plans/
|
||||
docs/specs/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -34,3 +36,4 @@ docker-compose.override.yml
|
||||
*.log
|
||||
.DS_Store
|
||||
.superpowers/
|
||||
.mcp.json
|
||||
|
||||
+8
-1
@@ -2,7 +2,7 @@
|
||||
FROM node:22-alpine AS build-frontend
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install -g npm@latest --quiet && npm install
|
||||
RUN npm ci --quiet
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
@@ -14,6 +14,13 @@ COPY pyproject.toml .
|
||||
COPY src/ src/
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# Build the fable-mcp wheel so it can be served for download
|
||||
COPY fable-mcp/ fable-mcp/
|
||||
RUN pip install --no-cache-dir build hatchling \
|
||||
&& python -m build --wheel ./fable-mcp --outdir /app/dist/ \
|
||||
&& pip uninstall -y build \
|
||||
&& rm -rf fable-mcp/ /root/.cache/pip
|
||||
|
||||
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
|
||||
COPY alembic.ini .
|
||||
COPY alembic/ alembic/
|
||||
|
||||
@@ -2,210 +2,41 @@
|
||||
|
||||
A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware.
|
||||
|
||||
## What It Does
|
||||
## Features
|
||||
|
||||
**Notes with inline formatting** — Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks without leaving the keyboard.
|
||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, an MCP server for external AI clients, and an Android companion app.
|
||||
|
||||
**Task tracking** — Notes convert freely to tasks (and back). Tasks carry status (`todo` → `in_progress` → `done`), priority, due date, sub-tasks, milestone assignment, and work logs with time tracking.
|
||||
## Quick Start
|
||||
|
||||
**Projects and milestones** — Group related notes and tasks into projects. Milestones give projects a timeline and show completion progress. A kanban-style project view groups tasks by milestone.
|
||||
**Prerequisites:** Docker and Docker Compose. 8 GB+ RAM recommended for LLM inference.
|
||||
|
||||
**Project Workspace** — `/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically.
|
||||
|
||||
**Wikilinks and backlinks** — Link notes with `[[Title]]` syntax. Click a wikilink to navigate to (or create) the referenced note. Each note shows what links to it. The editor suggests existing note titles as candidate links.
|
||||
|
||||
**Tag organisation** — Tags are first-class columns (stored as a PostgreSQL array, not extracted from body text). Tag autocomplete, tag-based filtering, and a force-directed graph view show how notes cluster.
|
||||
|
||||
**Knowledge graph** — `/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; project hub nodes (invisible) attract project members. Click any node to open a slide-in peek panel.
|
||||
|
||||
**AI chat** — Full conversation history with SSE streaming. The assistant automatically retrieves semantically relevant notes (RAG) and injects them as context. Attach specific notes by paperclip for focused discussions. Useful replies can be saved directly as notes.
|
||||
|
||||
**AI writing assistant** — Select a passage in the editor, give an instruction ("make this more concise", "add examples"), and stream a diff-style suggestion you can accept or reject.
|
||||
|
||||
**Web research** — The assistant can search the web (SearXNG), fetch pages, and synthesise findings. Research results are saved as notes. A lightweight `search_web` tool answers quick questions inline.
|
||||
|
||||
**Collaboration and sharing** — Share any project or note with other users or groups. Three permission levels: `viewer`, `editor`, `admin`. A "Shared with me" page lists all incoming shares. In-app notifications (with push) alert recipients when items are shared or when they are added to a group.
|
||||
|
||||
**Groups** — Admins can create platform-wide groups, assign users roles (`member` / `owner`), and share resources with the group in one action.
|
||||
|
||||
**In-app notifications** — A bell icon in the nav shows unread notification count with a 60-second polling interval. Clicking a notification navigates to the relevant resource and marks it read.
|
||||
|
||||
**CalDAV calendar** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) and have the assistant create, list, search, update, and delete calendar events via natural language.
|
||||
|
||||
**Push notifications** — Web Push (VAPID) notifies you when AI generation completes, even in another tab. Configurable per-user from the Notifications settings tab.
|
||||
|
||||
**PWA** — Installable as a desktop or mobile app. Service worker caches the shell; push is handled by `public/sw.js`.
|
||||
|
||||
**Data export and backup** — Export your data as a Markdown ZIP (with YAML frontmatter) or a JSON array from the Data settings tab. Admins can export/restore full application backups (version 2 includes projects, milestones, task logs, AI drafts, note versions, push subscriptions) with proper ID remapping on restore.
|
||||
|
||||
**OAuth / OIDC login** — Supports PKCE-based OIDC in addition to local username/password auth. `LOCAL_AUTH_ENABLED` can disable local login entirely for SSO-only deployments.
|
||||
|
||||
**Multi-user with isolation** — All data is scoped to the owning user. Access to shared resources is resolved through `services/access.py` using the permission rank system. The first registered user becomes admin.
|
||||
|
||||
**Daily Briefing** — A scheduled, dialogue-based morning briefing accessible at `/briefing`. The assistant compiles your tasks, calendar events, projects, weather forecast (Open-Meteo), and RSS feed digest at 4am, then checks in at 8am, 12pm, and 4pm. You can reply interactively — the briefing is a real conversation, not a widget. The assistant learns your preferences over time via a profile note it maintains. Configure locations, work schedule, RSS feeds, and active slots from Settings → Briefing.
|
||||
|
||||
**Dark and light themes** — Defaults to dark (slate-indigo palette). One-click toggle in the header.
|
||||
|
||||
**Keyboard shortcuts** — `g`+`h/n/t/p/c/g` navigate sections; `n` new note; `t` new task; `?` shows the shortcut panel. Full list available in-app.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||
- A machine with enough RAM to run an LLM (8 GB+ recommended for smaller models like `llama3.2`)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/your-username/fabledassistant.git
|
||||
cd fabledassistant
|
||||
```
|
||||
|
||||
2. Start the application:
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
3. Open `http://localhost:5000` in your browser.
|
||||
|
||||
4. Register the first user account — this account becomes the admin.
|
||||
|
||||
5. Go to **Settings → General** to pull an LLM model (`llama3.2` at 2 GB is a good starting point).
|
||||
|
||||
### Day-to-Day Usage
|
||||
|
||||
- **Create a note** from the Notes page. Use Markdown — formatting renders live.
|
||||
- **Link notes** by typing `[[` for a wikilink autocomplete dropdown.
|
||||
- **Tag your notes** — add tags in the sidebar tag input; autocomplete suggests existing tags.
|
||||
- **Use the AI writing assistant** — select text in the editor, write an instruction, stream a suggestion.
|
||||
- **Chat with the AI** — the assistant finds relevant notes automatically. Attach specific notes for focused context.
|
||||
- **Convert notes ↔ tasks** from the viewer toolbar.
|
||||
- **Open a project workspace** from the project page — chat, tasks, and note editor in one view.
|
||||
- **Share a project or note** — click Share in the project/note/task viewer toolbar, search for users or pick a group.
|
||||
- **Manage groups** (admins) — Settings → Groups tab.
|
||||
- **Backup your data** — Settings → Data tab for personal export; admin section for full application backup.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is via environment variables. See `docker-compose.yml` for defaults.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL connection string |
|
||||
| `SECRET_KEY` | (required) | Session signing key |
|
||||
| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint |
|
||||
| `DEFAULT_MODEL` | `llama3.1` | LLM model to warm on startup |
|
||||
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG |
|
||||
| `SECURE_COOKIES` | `false` | Set `true` behind TLS |
|
||||
| `LOG_LEVEL` | `INFO` | Logging verbosity |
|
||||
| `OIDC_ISSUER` | — | OIDC issuer URL for SSO login |
|
||||
| `OIDC_CLIENT_ID` | — | OIDC client ID |
|
||||
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
|
||||
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local login |
|
||||
| `SEARXNG_URL` | — | SearXNG base URL for web search |
|
||||
| `BASE_URL` | — | Public URL (used in email links and OIDC redirect) |
|
||||
|
||||
For production, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits.
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Reverse Proxy (Required)
|
||||
|
||||
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
|
||||
|
||||
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
|
||||
- Terminate TLS at the proxy; forward to port 5000
|
||||
- **Do not expose port 5000 directly to the internet**
|
||||
- **Rate-limit auth endpoints** — recommended: ≤5 req/min per IP on `/api/auth/login` and `/api/auth/register`
|
||||
- **Set CSP headers** — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'`
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- **Strong `SECRET_KEY`** — generate with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE`
|
||||
- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links.
|
||||
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions.
|
||||
- **Keep Ollama on an internal network** — both compose files keep it off the host network.
|
||||
|
||||
---
|
||||
|
||||
## Technical Overview
|
||||
|
||||
### Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router |
|
||||
| Editor | Tiptap (ProseMirror) with custom slash-command extension |
|
||||
| Backend | Python 3.12, Quart (async ASGI) |
|
||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic |
|
||||
| LLM | Ollama (local) — any OpenAI-compatible API |
|
||||
| Search | SearXNG (optional, self-hosted) |
|
||||
| Push | Web Push / VAPID (pywebpush 2.x) |
|
||||
| Deployment | Docker Compose |
|
||||
|
||||
### Architecture
|
||||
|
||||
The app runs as a single container serving the Vue SPA and REST API (`/api/`). The frontend is built by Vite during the Docker image build and served as static files by Quart.
|
||||
|
||||
LLM interactions stream via Server-Sent Events (SSE). Chat generation runs in background `asyncio` tasks with an in-memory event buffer supporting client reconnection without data loss. An abort mechanism lets users cancel in-flight generations.
|
||||
|
||||
Semantic search (RAG) uses `nomic-embed-text` via Ollama to generate embeddings stored in PostgreSQL. Notes above 0.60 cosine similarity are auto-injected into the system prompt; notes between 0.45–0.60 appear as sidebar suggestions.
|
||||
|
||||
Permission resolution is centralised in `services/access.py`. All resource access goes through `get_project_permission` / `get_note_permission`, which check ownership, direct shares, group membership shares, and note→project inheritance in order, returning the highest applicable permission.
|
||||
|
||||
### Database
|
||||
|
||||
PostgreSQL with SQLAlchemy 2.0 async. Tasks are notes with non-null `status` (unified `Note` model). Tags are stored as a `ARRAY[text]` column. Migrations run automatically on startup via Alembic.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
fabledassistant/
|
||||
├── docker-compose.yml # Development stack
|
||||
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
||||
├── Dockerfile # Multi-stage build (Node → Python)
|
||||
├── alembic/ # Database migrations
|
||||
├── src/fabledassistant/
|
||||
│ ├── app.py # Quart app factory + blueprint registration
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── routes/ # API blueprints
|
||||
│ ├── services/ # Business logic (access, sharing, groups, …)
|
||||
│ └── static/ # Built frontend (generated at build time)
|
||||
└── frontend/
|
||||
└── src/
|
||||
├── views/ # Page-level components
|
||||
├── components/ # Reusable UI components
|
||||
├── composables/ # Vue composables (autosave, shortcuts, …)
|
||||
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
|
||||
└── api/ # Typed API client (client.ts)
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
All development is done via Docker. No local dependency installation required.
|
||||
Download [`docker-compose.quickstart.yml`](docker-compose.quickstart.yml) from this repo, then:
|
||||
|
||||
```bash
|
||||
# Start the dev stack (hot-reload not included — rebuild on changes)
|
||||
docker compose up --build
|
||||
# Optional but recommended — set a secret key
|
||||
export SECRET_KEY=your-random-secret-here
|
||||
|
||||
# Reset the database
|
||||
docker compose down -v && docker compose up --build
|
||||
|
||||
# Lint, format, typecheck, test (runs inside Docker via Makefile)
|
||||
make check
|
||||
docker compose -f docker-compose.quickstart.yml up -d
|
||||
```
|
||||
|
||||
CI runs on Forgejo Actions with a custom runner image (`py3.12-node22`) that has Python 3.12 and Node 22 pre-installed. Pushes to `dev` build a `:dev` image; merges to `main` build `:latest`.
|
||||
Open `http://localhost:5000`. The first user to register becomes admin. Go to **Settings → General** to pull an LLM model — `qwen3:8b` or `llama3.1:8b` are good starting points.
|
||||
|
||||
---
|
||||
> **GPU:** Ollama runs CPU-only by default. See the comments in `docker-compose.quickstart.yml` to enable NVIDIA GPU passthrough.
|
||||
|
||||
> **Development:** To build from source, see [Development](docs/development.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| [Architecture](docs/architecture.md) | Stack, design decisions, data models, key services |
|
||||
| [Configuration](docs/configuration.md) | Environment variables, Docker Compose, production setup, security |
|
||||
| [Features](docs/features.md) | Detailed feature breakdown and keyboard shortcuts |
|
||||
| [Development](docs/development.md) | Dev workflow, CI/CD, migrations, release process |
|
||||
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
|
||||
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
|
||||
| [API Reference](docs/api-reference.md) | All REST API endpoints |
|
||||
| [Android App](docs/android-app.md) | Flutter companion app architecture and feature status |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add api_keys table
|
||||
|
||||
Revision ID: 0027
|
||||
Revises: 0026
|
||||
Create Date: 2026-03-23
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0027"
|
||||
down_revision = "0026"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("key_hash", sa.Text(), nullable=False, unique=True),
|
||||
sa.Column("key_prefix", sa.Text(), nullable=False),
|
||||
sa.Column("scope", sa.Text(), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
|
||||
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("api_keys")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Add briefing improvements: rss_items topics/classified_at, messages metadata,
|
||||
rss_item_reactions, briefing_task_snapshot."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0028"
|
||||
down_revision = "0027"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE rss_items
|
||||
ADD COLUMN IF NOT EXISTS topics TEXT[] DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS classified_at TIMESTAMPTZ
|
||||
""")
|
||||
op.execute("""
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS metadata JSONB
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rss_item_reactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rss_item_id INTEGER NOT NULL REFERENCES rss_items(id) ON DELETE CASCADE,
|
||||
reaction TEXT NOT NULL CHECK (reaction IN ('up', 'down')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (user_id, rss_item_id)
|
||||
)
|
||||
""")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_rss_item_reactions_user_id "
|
||||
"ON rss_item_reactions(user_id)"
|
||||
)
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS briefing_task_snapshot (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
task_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
snapshot_hash TEXT NOT NULL,
|
||||
last_briefed TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (user_id, task_id)
|
||||
)
|
||||
""")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_briefing_task_snapshot_user_id "
|
||||
"ON briefing_task_snapshot(user_id)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS briefing_task_snapshot")
|
||||
op.execute("DROP TABLE IF EXISTS rss_item_reactions")
|
||||
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS metadata")
|
||||
op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS classified_at")
|
||||
op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS topics")
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Add caldav_uid and color columns to events table."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0029"
|
||||
down_revision = "0028"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE events
|
||||
ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS color TEXT DEFAULT ''
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE events DROP COLUMN IF EXISTS color")
|
||||
op.execute("ALTER TABLE events DROP COLUMN IF EXISTS caldav_uid")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Add rag_project_id to conversations; auto_summary columns to projects."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0030"
|
||||
down_revision = "0029"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE conversations
|
||||
ADD COLUMN IF NOT EXISTS rag_project_id INTEGER DEFAULT NULL
|
||||
""")
|
||||
op.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS auto_summary TEXT DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS summary_updated_at TIMESTAMPTZ DEFAULT NULL
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS rag_project_id")
|
||||
op.execute("ALTER TABLE projects DROP COLUMN IF EXISTS auto_summary, DROP COLUMN IF EXISTS summary_updated_at")
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Add 'cancelled' task status.
|
||||
|
||||
The status column is plain TEXT (not a PostgreSQL enum type), so no DDL
|
||||
change is required — the application layer already accepts the new value.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0031"
|
||||
down_revision = "0030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# No-op: status is stored as TEXT; the new value is valid without DDL changes.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Add started_at and completed_at to notes."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0032"
|
||||
down_revision = "0031"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("started_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("notes", sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("notes", "completed_at")
|
||||
op.drop_column("notes", "started_at")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Add recurrence_rule and recurrence_next_spawn_at to notes."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision = "0033"
|
||||
down_revision = "0032"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("recurrence_rule", JSONB(), nullable=True))
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("recurrence_next_spawn_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_notes_recurrence_next_spawn_at",
|
||||
"notes",
|
||||
["recurrence_next_spawn_at"],
|
||||
postgresql_where=sa.text("recurrence_next_spawn_at IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_notes_recurrence_next_spawn_at", table_name="notes")
|
||||
op.drop_column("notes", "recurrence_next_spawn_at")
|
||||
op.drop_column("notes", "recurrence_rule")
|
||||
@@ -0,0 +1,82 @@
|
||||
# Fabled Assistant — Quick Start
|
||||
#
|
||||
# No build required. Pulls the latest pre-built image from the registry.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Download this file
|
||||
# 2. docker compose -f docker-compose.quickstart.yml up -d
|
||||
# 3. Open http://localhost:5000 — the first account registered becomes admin
|
||||
# 4. Go to Settings → General to pull an LLM model (qwen3:8b or llama3.1:8b are good starting points)
|
||||
#
|
||||
# Set SECRET_KEY via environment variable or a .env file alongside this file:
|
||||
# SECRET_KEY=your-random-secret-here
|
||||
|
||||
services:
|
||||
app:
|
||||
image: git.fabledsword.com/bvandeusen/fabledassistant:latest
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant"
|
||||
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
|
||||
OLLAMA_URL: "http://ollama:11434"
|
||||
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1:8b}"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||
volumes:
|
||||
- app_data:/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: fabled
|
||||
POSTGRES_PASSWORD: fabled
|
||||
POSTGRES_DB: fabledassistant
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fabled"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
environment:
|
||||
OLLAMA_MAX_LOADED_MODELS: "2"
|
||||
OLLAMA_KEEP_ALIVE: "30m"
|
||||
OLLAMA_FLASH_ATTENTION: "1"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "ollama list > /dev/null 2>&1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
restart: unless-stopped
|
||||
# Uncomment to enable NVIDIA GPU passthrough (requires nvidia-container-toolkit):
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: all
|
||||
# capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
pgdata:
|
||||
ollama_models:
|
||||
+8
-7
@@ -55,13 +55,14 @@ services:
|
||||
OLLAMA_NUM_PARALLEL: "2"
|
||||
OLLAMA_KEEP_ALIVE: "30m"
|
||||
OLLAMA_FLASH_ATTENTION: "1"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
# GPU reservation commented out — no nvidia-container-toolkit on this host
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: all
|
||||
# capabilities: [gpu]
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# Fable MCP — Design Spec
|
||||
|
||||
**Date:** 2026-03-23
|
||||
**Status:** Approved
|
||||
**Author:** bvandeusen + Claude
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions.
|
||||
|
||||
The work is split into two sub-projects:
|
||||
|
||||
1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication
|
||||
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
|
||||
|
||||
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
|
||||
|
||||
---
|
||||
|
||||
## Sub-project 1: Fable API Key Feature
|
||||
|
||||
### Motivation
|
||||
|
||||
All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level.
|
||||
|
||||
### Database
|
||||
|
||||
New table `api_keys` via migration `0027_add_api_keys.py`:
|
||||
- `down_revision = "0026"` (the briefing tables migration)
|
||||
- `revision = "0027"`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | Integer PK | |
|
||||
| `user_id` | Integer FK → users | CASCADE delete |
|
||||
| `name` | Text | Human-readable label |
|
||||
| `key_hash` | Text | SHA-256 of the full key, used for lookup |
|
||||
| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI |
|
||||
| `scope` | Text | `"read"` or `"write"` |
|
||||
| `last_used_at` | Timestamp | Nullable, updated on each authenticated request |
|
||||
| `created_at` | Timestamp | |
|
||||
| `revoked_at` | Timestamp | Nullable — soft delete |
|
||||
|
||||
Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted.
|
||||
|
||||
### Auth Middleware (`auth.py`)
|
||||
|
||||
`_check_auth` is updated to check the `Authorization: Bearer <token>` header before falling back to session auth:
|
||||
|
||||
1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL`
|
||||
2. If found: update `last_used_at`, set `g.user` and `g.api_key`
|
||||
3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403
|
||||
4. If header absent: existing session logic runs unchanged
|
||||
|
||||
Session auth is untouched — no regression risk for the web UI.
|
||||
|
||||
**Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role.
|
||||
|
||||
### Routes (`/api/api-keys`)
|
||||
|
||||
New blueprint `api_keys_bp`, registered in `app.py`:
|
||||
|
||||
- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key)
|
||||
- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only**
|
||||
- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()`
|
||||
|
||||
### Settings UI
|
||||
|
||||
New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`):
|
||||
|
||||
- Create form: name input + read/write radio/toggle + "Generate Key" button
|
||||
- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again
|
||||
- Keys table: columns for name, scope badge, prefix, last used, revoke button
|
||||
- Revoke shows inline confirmation before calling DELETE
|
||||
|
||||
### New Search Endpoint
|
||||
|
||||
`GET /api/search?q=<query>&content_type=note|task|all&limit=N`
|
||||
|
||||
Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument:
|
||||
- `content_type=note` → `is_task=False`
|
||||
- `content_type=task` → `is_task=True`
|
||||
- `content_type=all` (default) → `is_task=None`
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]}
|
||||
],
|
||||
"total": 5
|
||||
}
|
||||
```
|
||||
|
||||
This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work.
|
||||
|
||||
### Conversation Type: `"mcp"`
|
||||
|
||||
A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app:
|
||||
|
||||
**`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor.
|
||||
|
||||
**`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations.
|
||||
|
||||
**Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned.
|
||||
|
||||
MCP conversations:
|
||||
- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named
|
||||
- Are excluded from the default chat list
|
||||
- Are accessible via `GET /api/chat/conversations?type=mcp` for inspection
|
||||
- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions
|
||||
|
||||
### Chat SSE Wire Format
|
||||
|
||||
The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema:
|
||||
|
||||
- **Create conversation:** `POST /api/chat/conversations` → `{id, title, ...}`
|
||||
- **Post message + start generation:** `POST /api/chat/conversations/:id/messages` → `{message_id, ...}`; then connect to:
|
||||
- **Stream:** `GET /api/chat/conversations/:id/stream` (SSE)
|
||||
|
||||
SSE event format (each line is `data: <json>`):
|
||||
- `{"type": "token", "content": "..."}` — streaming token
|
||||
- `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired
|
||||
- `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event
|
||||
|
||||
The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names).
|
||||
|
||||
---
|
||||
|
||||
## Sub-project 2: Fable MCP Server
|
||||
|
||||
### Location
|
||||
|
||||
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable.
|
||||
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
fable-mcp/
|
||||
pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main"
|
||||
README.md
|
||||
.env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_...
|
||||
fable_mcp/
|
||||
__init__.py
|
||||
server.py # FastMCP instance, imports + registers all tool modules
|
||||
client.py # FableClient: async httpx wrapper, env var config, FableAPIError
|
||||
tools/
|
||||
__init__.py
|
||||
notes.py # list_notes, get_note, create_note, update_note, delete_note
|
||||
tasks.py # list_tasks, get_task, create_task, update_task, delete_task,
|
||||
# patch_task_status, add_task_log
|
||||
projects.py # list_projects, get_project, create_project, update_project,
|
||||
# delete_project, get_project_summary
|
||||
milestones.py # list_milestones, get_milestone, create_milestone,
|
||||
# update_milestone, delete_milestone
|
||||
search.py # semantic_search — calls GET /api/search
|
||||
chat.py # send_message — creates/continues conversation, returns response
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.0",
|
||||
"httpx>=0.27",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
```
|
||||
|
||||
### `client.py`
|
||||
|
||||
`FableClient` is an async context manager wrapping `httpx.AsyncClient`. It reads `FABLE_URL` and `FABLE_API_KEY` from environment variables (with `python-dotenv` fallback to `.env`). All methods are `async def` and return parsed dicts. A `FableAPIError(status_code, message)` exception is raised for any non-2xx response.
|
||||
|
||||
A module-level singleton `_client: FableClient` is initialized at server startup and shared across all tool modules.
|
||||
|
||||
### `server.py`
|
||||
|
||||
Uses `mcp[cli]`'s `FastMCP` class. Imports all tool modules, which register their tools via decorators against the shared `FastMCP` instance. Entry point `main()` calls `mcp.run()` (stdio transport).
|
||||
|
||||
### Tool Modules
|
||||
|
||||
Each module imports `_client` from `client.py` and defines tools as `async def` functions decorated with `@mcp.tool()`. Tool docstrings serve as the MCP tool descriptions visible to Claude.
|
||||
|
||||
**`notes.py`** tools:
|
||||
- `list_notes(query, tags, project_id, limit, offset)`
|
||||
- `get_note(note_id)`
|
||||
- `create_note(title, body, tags, project_id)`
|
||||
- `update_note(note_id, title, body, tags, project_id)`
|
||||
- `delete_note(note_id)`
|
||||
|
||||
**`tasks.py`** tools:
|
||||
- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)`
|
||||
- `get_task(task_id)`
|
||||
- `create_task(title, body, project_id, milestone_id, priority, status)`
|
||||
- `update_task(task_id, title, body, project_id, milestone_id, priority, status)`
|
||||
- `delete_task(task_id)`
|
||||
- `patch_task_status(task_id, status)`
|
||||
- `add_task_log(task_id, content)`
|
||||
|
||||
**`projects.py`** tools:
|
||||
- `list_projects()`
|
||||
- `get_project(project_id)` — includes milestone summary
|
||||
- `create_project(title, description, goal, color)`
|
||||
- `update_project(project_id, title, description, goal, status, color)`
|
||||
- `delete_project(project_id)`
|
||||
|
||||
**`milestones.py`** tools:
|
||||
- `list_milestones(project_id, status)`
|
||||
- `get_milestone(project_id, milestone_id)`
|
||||
- `create_milestone(project_id, title, description, order_index)`
|
||||
- `update_milestone(project_id, milestone_id, title, description, status, order_index)`
|
||||
- `delete_milestone(project_id, milestone_id)`
|
||||
|
||||
**`search.py`** tools:
|
||||
- `semantic_search(query, content_type, limit)` — `content_type` is `"note"`, `"task"`, or `"all"` (avoids shadowing Python's `type` builtin). Calls `GET /api/search`, returns ranked results with similarity scores.
|
||||
|
||||
**`chat.py`** tools:
|
||||
- `send_message(message, conversation_name)`:
|
||||
- Looks up or creates a Fable conversation with the given name and `conversation_type="mcp"`
|
||||
- Posts the message via `POST /api/chat/conversations/:id/messages`
|
||||
- Consumes SSE stream from `GET /api/chat/conversations/:id/stream` until `type: "done"`
|
||||
- Returns: `{response: str, tools_used: [str], conversation_id: int}`
|
||||
- MCP conversations are excluded from the normal chat UI list
|
||||
|
||||
### Error Handling
|
||||
|
||||
`FableAPIError` is caught at each tool boundary and returned as a descriptive string rather than propagating as an exception. This ensures Claude receives a readable error message (e.g., `"Fable API error 403: Read-only key cannot perform write operations."`) rather than a stack trace. Network errors (`httpx.RequestError`) are similarly caught and surfaced as strings.
|
||||
|
||||
Scope enforcement lives entirely in Fable's auth middleware — the MCP does not duplicate it.
|
||||
|
||||
### Claude Code Registration
|
||||
|
||||
After `pip install -e fable-mcp/` (or `uv tool install ./fable-mcp`), add to `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fable": {
|
||||
"command": "fable-mcp",
|
||||
"env": {
|
||||
"FABLE_URL": "http://localhost:8080",
|
||||
"FABLE_API_KEY": "fmcp_your_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Claude Code spawns the process over stdio automatically. No Docker, no daemon.
|
||||
|
||||
---
|
||||
|
||||
## Build & Repo Plan
|
||||
|
||||
1. Implement and test within `fabledassistant/fable-mcp/`
|
||||
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
|
||||
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (v1)
|
||||
|
||||
- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code
|
||||
- Forgejo MCP — follow-on spec
|
||||
- Rate limiting on API key endpoints
|
||||
- Key expiry / TTL
|
||||
- Per-resource scope (e.g., key restricted to one project)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
# Android Companion App
|
||||
|
||||
The Android companion app lives in a separate repository at `/home/bvandeusen/Nextcloud/Projects/fabled_app`.
|
||||
|
||||
## Stack
|
||||
|
||||
- Flutter + Dart
|
||||
- Riverpod (state management)
|
||||
- GoRouter (navigation)
|
||||
- Dio (HTTP client)
|
||||
- PersistCookieJar (session persistence)
|
||||
- SSE streaming via `fetch` + `ReadableStream` bridge
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lib/
|
||||
app.dart # GoRouter + _Shell + _QuickCaptureBar
|
||||
core/constants.dart # Routes.*
|
||||
data/
|
||||
models/ # note.dart, task.dart, project.dart
|
||||
api/ # notes_api.dart, tasks_api.dart, projects_api.dart
|
||||
repositories/ # notes, tasks, projects repositories
|
||||
providers/
|
||||
api_client_provider.dart # all API + repository providers
|
||||
notes_provider.dart # NotesNotifier
|
||||
tasks_provider.dart # TasksNotifier
|
||||
projects_provider.dart # ProjectsNotifier
|
||||
screens/
|
||||
notes/note_edit_screen.dart # chip tag input + ProjectSelector
|
||||
tasks/task_edit_screen.dart # ProjectSelector
|
||||
projects/project_list_screen.dart
|
||||
widgets/
|
||||
project_selector.dart # reusable DropdownButtonFormField
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
4-tab shell (Notes · Tasks · Projects · Chat):
|
||||
- Phone: bottom `NavigationBar`
|
||||
- Tablet/landscape: `NavigationRail`
|
||||
|
||||
Quick Capture bar persists across all tabs. Settings accessible from top-right icon.
|
||||
|
||||
## Feature Status
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Notes CRUD | ✅ | Tags chip input; project selector in editor |
|
||||
| Tasks CRUD | ✅ | Project selector in editor |
|
||||
| Projects list | ✅ | Active/archived sections; long-press status change; create dialog |
|
||||
| Chat + SSE | ✅ | Full streaming |
|
||||
| Quick Capture | ✅ | Offline queue with retry |
|
||||
| Tags | ✅ | Chip input in NoteEditScreen; typed as `List<String>` |
|
||||
| Project assignment | ✅ | `ProjectSelector` dropdown in Note + Task editors |
|
||||
| Milestones | ❌ deferred | Too granular for mobile; web UI handles it |
|
||||
| Push notifications | ❌ incompatible | Backend uses browser VAPID; Flutter needs FCM/APNs — separate implementation required |
|
||||
| CalDAV settings | ❌ intentional | Server-side config only; not exposed in mobile app |
|
||||
|
||||
## API Compatibility Notes
|
||||
|
||||
- `GET /api/projects/:id` returns a flat JSON object (not `{project: ...}` wrapper); includes `summary` field.
|
||||
- `POST /api/projects` returns the project dict directly (201).
|
||||
- `PATCH /api/projects/:id` returns the updated project dict.
|
||||
- Task body field is `body` (not `description`) — the app maps `description` → `body` on serialize.
|
||||
|
||||
## Self-Update
|
||||
|
||||
The app supports self-update via the Forgejo release API (`update_provider.dart`). It checks the latest release tag and prompts the user to download and install a new APK when one is available.
|
||||
|
||||
## CI
|
||||
|
||||
Builds are triggered from the Forgejo Actions pipeline in the `fabled_app` repository. The APK is attached to the release as a downloadable artifact.
|
||||
@@ -0,0 +1,144 @@
|
||||
# API Keys and Fable MCP
|
||||
|
||||
## API Keys
|
||||
|
||||
API keys let external tools access your Fable data without a browser session. Each key is scoped to a single user — it can only access data that user owns or has been shared with them.
|
||||
|
||||
### Scopes
|
||||
|
||||
| Scope | Permissions |
|
||||
|-------|-------------|
|
||||
| `read` | GET endpoints only — list, search, fetch content |
|
||||
| `write` | Full read + create, update, delete |
|
||||
|
||||
Admin-level operations (log access, user management) require a `write`-scoped key from an admin account.
|
||||
|
||||
### Creating a Key
|
||||
|
||||
1. Go to **Settings → API Keys**
|
||||
2. Enter a name (e.g. "Claude MCP", "Home Server")
|
||||
3. Choose scope
|
||||
4. Click **Generate Key**
|
||||
5. Copy the key immediately — it is shown only once
|
||||
|
||||
After creation you can download:
|
||||
- **`.env` file** — `FABLE_URL` + `FABLE_API_KEY` ready to paste
|
||||
- **Claude config JSON** — `mcpServers` block ready to merge into `~/.claude.json`
|
||||
|
||||
### Revoking a Key
|
||||
|
||||
Click **Revoke** next to the key in the API Keys table and confirm. Revoked keys are deleted immediately.
|
||||
|
||||
---
|
||||
|
||||
## Fable MCP Server
|
||||
|
||||
The Fable MCP server (`fable-mcp`) exposes Fable as a set of MCP tools that Claude (and other MCP clients) can use to read and write your notes, tasks, projects, and more.
|
||||
|
||||
### Download
|
||||
|
||||
The wheel is bundled into the Docker image at build time and available for download from **Settings → API Keys → Fable MCP** when you are logged in.
|
||||
|
||||
You can also download it directly:
|
||||
```
|
||||
GET /api/fable-mcp/download
|
||||
```
|
||||
(Requires login — authenticated browser session or API key in `Authorization: Bearer <key>` header.)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install the wheel
|
||||
pip install fable_mcp-*.whl
|
||||
|
||||
# Verify
|
||||
fable-mcp --help
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The server reads two environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `FABLE_URL` | Base URL of your Fable instance (e.g. `https://notes.example.com`) |
|
||||
| `FABLE_API_KEY` | API key generated from Settings → API Keys |
|
||||
|
||||
Create a `.env` file in your working directory, or set them in your shell / MCP config.
|
||||
|
||||
### Claude Code (Global)
|
||||
|
||||
Add to `~/.claude.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fable": {
|
||||
"type": "stdio",
|
||||
"command": "fable-mcp",
|
||||
"env": {
|
||||
"FABLE_URL": "https://your-fable-instance.example.com",
|
||||
"FABLE_API_KEY": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Code (Project-scoped)
|
||||
|
||||
Add a `.mcp.json` at the project root (same format as the global config). Project-scoped config takes precedence over global when the same server name is defined in both. This is useful for using a dev instance or admin key within a specific project.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fable": {
|
||||
"type": "stdio",
|
||||
"command": "fable-mcp",
|
||||
"env": {
|
||||
"FABLE_URL": "http://localhost:5000",
|
||||
"FABLE_API_KEY": "your-dev-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `.mcp.json` contains an API key and should be added to `.gitignore`.
|
||||
|
||||
### Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `fable_list_notes` | List notes, filter by tag or search text |
|
||||
| `fable_get_note` | Fetch a note by ID |
|
||||
| `fable_create_note` | Create a new note |
|
||||
| `fable_update_note` | Update a note |
|
||||
| `fable_delete_note` | Delete a note |
|
||||
| `fable_list_tasks` | List tasks, filter by status or project |
|
||||
| `fable_get_task` | Fetch a task by ID |
|
||||
| `fable_create_task` | Create a new task |
|
||||
| `fable_update_task` | Update a task |
|
||||
| `fable_add_task_log` | Append a work log entry to a task |
|
||||
| `fable_list_projects` | List all projects |
|
||||
| `fable_get_project` | Fetch a project with milestone summary |
|
||||
| `fable_create_project` | Create a project |
|
||||
| `fable_update_project` | Update a project |
|
||||
| `fable_list_milestones` | List milestones for a project |
|
||||
| `fable_create_milestone` | Create a milestone |
|
||||
| `fable_update_milestone` | Update a milestone |
|
||||
| `fable_search` | Semantic search over notes and tasks |
|
||||
| `fable_list_conversations` | List MCP chat conversations |
|
||||
| `fable_send_message` | Send a message to Fable's LLM |
|
||||
| `fable_get_app_logs` | Fetch application logs (admin key required) |
|
||||
|
||||
### Development Notes
|
||||
|
||||
The `fable-mcp` package lives in `fable-mcp/` in this repository. The Docker build compiles it into a wheel at `/app/dist/` so it can be served for download without requiring the source tree at runtime.
|
||||
|
||||
To build the wheel locally:
|
||||
```bash
|
||||
cd fable-mcp
|
||||
pip install build hatchling
|
||||
python -m build --wheel .
|
||||
```
|
||||
@@ -0,0 +1,244 @@
|
||||
# API Reference
|
||||
|
||||
All endpoints require login (session cookie or `Authorization: Bearer <api-key>`) unless marked **(public)**.
|
||||
|
||||
## Health
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Health check **(public)** |
|
||||
|
||||
## Auth
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/auth/status` | `{has_users, registration_open, oauth_enabled, local_auth_enabled}` **(public)** |
|
||||
| POST | `/api/auth/register` | Register new user (first user becomes admin; 403 if registration closed or local auth disabled) |
|
||||
| POST | `/api/auth/login` | Login with username/password (403 if local auth disabled) |
|
||||
| POST | `/api/auth/logout` | Clear session |
|
||||
| GET | `/api/auth/me` | Current user info (includes `has_password: bool`) |
|
||||
| PUT | `/api/auth/password` | Change password `{current_password, new_password}` |
|
||||
| PUT | `/api/auth/email` | Change email `{email, password?}` (password required only for local-auth users) |
|
||||
| POST | `/api/auth/invalidate-sessions` | Bump `session_version` — evicts all other sessions, keeps current alive |
|
||||
| POST | `/api/auth/forgot-password` | Send password reset email `{email}` |
|
||||
| POST | `/api/auth/reset-password` | Reset password with token `{token, new_password}` |
|
||||
| GET | `/api/auth/oauth/login` | Initiate OIDC PKCE flow → redirect to provider |
|
||||
| GET | `/api/auth/oauth/callback` | OIDC callback — exchange code, find/create user, redirect to `/` |
|
||||
| GET | `/api/auth/invitation/:token` | Validate invitation token **(public)** |
|
||||
| POST | `/api/auth/register-with-invite` | Register with token `{token, username, password}` **(public)** |
|
||||
|
||||
## Notes
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/notes` | List notes. Params: `q`, `tag`, `sort`, `order`, `limit`, `offset`, `project_id`, `milestone_id`, `parent_id`, `type` (`note`/`task`/`all`) |
|
||||
| POST | `/api/notes` | Create note `{title, body, tags?, status?, priority?, due_date?, project_id?, milestone_id?, parent_id?}` |
|
||||
| GET | `/api/notes/tags` | All tags (param: `q` for filter) |
|
||||
| POST | `/api/notes/suggest-tags` | LLM tag suggestions `{title, body, current_tags?}` → `{suggested_tags}` |
|
||||
| POST | `/api/notes/link-suggestions` | Detect note titles as plain text in body `{body, project_id, exclude_note_id}` → `[{note_id, title, count}]` |
|
||||
| GET | `/api/notes/by-title` | Resolve note by exact title (param: `title`) |
|
||||
| POST | `/api/notes/resolve-title` | Get-or-create note by title `{title}` (wikilink click) |
|
||||
| GET | `/api/notes/:id` | Get single note |
|
||||
| PUT | `/api/notes/:id` | Full update |
|
||||
| PATCH | `/api/notes/:id` | Partial update (same fields as PUT) |
|
||||
| DELETE | `/api/notes/:id` | Delete note |
|
||||
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` |
|
||||
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` |
|
||||
| POST | `/api/notes/:id/append-tag` | Add tag `{tag}` → updated note |
|
||||
| GET | `/api/notes/:id/backlinks` | Notes/tasks with `[[Title]]` references to this note |
|
||||
| GET | `/api/notes/:id/versions` | List note version history |
|
||||
| GET | `/api/notes/:id/versions/:vid` | Get a specific version |
|
||||
| GET | `/api/notes/:id/draft` | Get current AI draft |
|
||||
| PUT | `/api/notes/:id/draft` | Save AI draft |
|
||||
| DELETE | `/api/notes/:id/draft` | Delete AI draft |
|
||||
| POST | `/api/notes/assist` | Launch AI assist generation → 202 `{body, target_section?, instruction, whole_doc?}` |
|
||||
| GET | `/api/notes/assist/stream` | SSE stream for assist (Last-Event-ID reconnect; events: `chunk`, `done`, `error`) |
|
||||
|
||||
## Tasks
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/tasks` | List tasks. Params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset` |
|
||||
| POST | `/api/tasks` | Create task (accepts `project` name string → resolved to `project_id`) |
|
||||
| GET | `/api/tasks/:id` | Get task (includes `parent_title`) |
|
||||
| PUT | `/api/tasks/:id` | Full update |
|
||||
| PATCH | `/api/tasks/:id/status` | Quick status update `{status}` |
|
||||
| DELETE | `/api/tasks/:id` | Delete task |
|
||||
| GET | `/api/tasks/:id/logs` | List work logs |
|
||||
| POST | `/api/tasks/:id/logs` | Create log `{content, duration_minutes?}` |
|
||||
| PATCH | `/api/tasks/:id/logs/:log_id` | Update log |
|
||||
| DELETE | `/api/tasks/:id/logs/:log_id` | Delete log |
|
||||
|
||||
## Projects & Milestones
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/projects` | List projects (owned + shared) |
|
||||
| POST | `/api/projects` | Create project |
|
||||
| GET | `/api/projects/:id` | Get project with `milestone_summary` |
|
||||
| PATCH | `/api/projects/:id` | Update project |
|
||||
| DELETE | `/api/projects/:id` | Delete project |
|
||||
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
||||
| GET | `/api/projects/:id/milestones` | List milestones |
|
||||
| POST | `/api/projects/:id/milestones` | Create milestone |
|
||||
| PATCH | `/api/projects/:id/milestones/:mid` | Update milestone |
|
||||
| DELETE | `/api/projects/:id/milestones/:mid` | Delete milestone |
|
||||
|
||||
## Sharing
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/projects/:id/shares` | List project shares |
|
||||
| POST | `/api/projects/:id/shares` | Create project share `{user_id?, group_id?, permission}` |
|
||||
| PATCH | `/api/projects/:id/shares/:sid` | Update permission |
|
||||
| DELETE | `/api/projects/:id/shares/:sid` | Remove share |
|
||||
| GET | `/api/notes/:id/shares` | List note shares |
|
||||
| POST | `/api/notes/:id/shares` | Create note share |
|
||||
| PATCH | `/api/notes/:id/shares/:sid` | Update permission |
|
||||
| DELETE | `/api/notes/:id/shares/:sid` | Remove share |
|
||||
| GET | `/api/shared-with-me` | All resources shared with the current user |
|
||||
|
||||
## Groups
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/groups` | List all groups (admin only) |
|
||||
| POST | `/api/groups` | Create group `{name, description?}` |
|
||||
| PATCH | `/api/groups/:id` | Update group |
|
||||
| DELETE | `/api/groups/:id` | Delete group |
|
||||
| GET | `/api/groups/:id/members` | List members |
|
||||
| POST | `/api/groups/:id/members` | Add member `{user_id, role}` |
|
||||
| PATCH | `/api/groups/:id/members/:uid` | Update member role |
|
||||
| DELETE | `/api/groups/:id/members/:uid` | Remove member |
|
||||
|
||||
## Chat
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
|
||||
| POST | `/api/chat/conversations` | Create conversation `{title?, model?}` |
|
||||
| POST | `/api/chat/conversations/bulk-delete` | Delete multiple conversations `{ids: number[]}` |
|
||||
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
|
||||
| PATCH | `/api/chat/conversations/:id` | Update title or model |
|
||||
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
|
||||
| POST | `/api/chat/conversations/:id/messages` | Start generation → 202. Body: `{content, context_note_id?, include_note_ids?, rag_project_id?, workspace_project_id?, think?}` |
|
||||
| GET | `/api/chat/conversations/:id/generation/stream` | SSE stream (Last-Event-ID reconnect; events: `context`, `chunk`, `tool_call`, `status`, `done`, `error`) |
|
||||
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation |
|
||||
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as note |
|
||||
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation → note |
|
||||
| GET | `/api/chat/status` | Ollama availability + model state `{ollama, model, default_model}` |
|
||||
| GET | `/api/chat/models` | List installed Ollama models (includes `loaded: bool`, `modified_at`) |
|
||||
| POST | `/api/chat/models/pull` | Pull model (SSE NDJSON progress) `{model}` |
|
||||
| POST | `/api/chat/models/delete` | Delete model `{model}` |
|
||||
| GET | `/api/chat/ps` | Currently loaded (hot) models |
|
||||
| POST | `/api/chat/warm` | Pre-load model into VRAM `{model}` → 202 |
|
||||
|
||||
## Quick Capture
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/quick-capture` | Classify + create item from natural language `{text}` → `{success, type, message, data}` |
|
||||
|
||||
## Search
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
|
||||
|
||||
## Briefing
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/briefing/config` | Get briefing configuration |
|
||||
| PUT | `/api/briefing/config` | Save briefing configuration |
|
||||
| GET | `/api/briefing/feeds` | List RSS feeds |
|
||||
| POST | `/api/briefing/feeds` | Add RSS feed `{url, name?, category?}` |
|
||||
| DELETE | `/api/briefing/feeds/:id` | Delete feed |
|
||||
| POST | `/api/briefing/feeds/refresh` | Trigger immediate feed refresh → `{feeds_refreshed, new_items}` |
|
||||
| GET | `/api/briefing/weather` | Get weather configuration |
|
||||
| PUT | `/api/briefing/weather` | Save weather locations |
|
||||
| POST | `/api/briefing/weather/geocode` | Geocode address `{query}` → `{lat, lon, label}` |
|
||||
| POST | `/api/briefing/trigger` | Manually fire a briefing slot `{slot}` |
|
||||
| GET | `/api/briefing/conversations` | List past briefing conversations |
|
||||
| GET | `/api/briefing/conversations/today` | Get/create today's briefing conversation |
|
||||
| GET | `/api/briefing/conversations/:id/messages` | Get messages for a briefing conversation |
|
||||
|
||||
## Settings
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/settings` | All settings as `{key: value}` |
|
||||
| PUT | `/api/settings` | Update settings `{key: value, ...}` |
|
||||
| GET | `/api/settings/models` | Installed models + defaults |
|
||||
| GET | `/api/settings/search` | Proxy SearXNG search (params: `q`) |
|
||||
|
||||
## API Keys
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/api-keys` | List user's API keys |
|
||||
| POST | `/api/api-keys` | Create key `{name, scope}` → `{key, ...}` (key shown once) |
|
||||
| DELETE | `/api/api-keys/:id` | Revoke key |
|
||||
|
||||
## Fable MCP Distribution
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/fable-mcp/info` | `{available: bool, filename: string\|null}` |
|
||||
| GET | `/api/fable-mcp/download` | Download wheel file |
|
||||
|
||||
## Notifications
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/notifications` | List notifications |
|
||||
| GET | `/api/notifications/count` | Unread count |
|
||||
| POST | `/api/notifications/:id/read` | Mark read |
|
||||
| POST | `/api/notifications/read-all` | Mark all read |
|
||||
|
||||
## Push
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/push/vapid-public-key` | VAPID public key for subscription |
|
||||
| POST | `/api/push/subscribe` | Register push subscription |
|
||||
| DELETE | `/api/push/subscribe` | Unregister push subscription |
|
||||
|
||||
## Images
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/images/:id` | Serve cached image **(no auth required — IDs are opaque SHA-256)** |
|
||||
|
||||
## Users
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/users/search` | Search users by username/email prefix (param: `q`, min 2 chars, excludes self) |
|
||||
|
||||
## Export
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/export` | Export data. Params: `format=markdown` (ZIP with `.md` + YAML frontmatter) or `format=json` |
|
||||
|
||||
## Admin
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data; full requires admin) |
|
||||
| POST | `/api/admin/restore` | Restore from JSON backup |
|
||||
| GET | `/api/admin/users` | List all users |
|
||||
| DELETE | `/api/admin/users/:id` | Delete user (cannot delete self) |
|
||||
| GET | `/api/admin/registration` | Get registration open/closed state |
|
||||
| PUT | `/api/admin/registration` | Toggle registration `{open: bool}` |
|
||||
| POST | `/api/admin/invitations` | Create invitation `{email}` → sends email |
|
||||
| GET | `/api/admin/invitations` | List pending invitations |
|
||||
| DELETE | `/api/admin/invitations/:id` | Revoke invitation |
|
||||
| GET | `/api/admin/logs` | Log entries. Params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset` |
|
||||
| GET | `/api/admin/logs/stats` | Log category counts |
|
||||
| GET | `/api/admin/base-url` | Get base URL setting |
|
||||
| PUT | `/api/admin/base-url` | Set base URL `{base_url}` |
|
||||
| GET | `/api/admin/smtp` | Get SMTP config (password masked) |
|
||||
| PUT | `/api/admin/smtp` | Save SMTP config |
|
||||
| POST | `/api/admin/smtp/test` | Send test email `{recipient}` |
|
||||
@@ -0,0 +1,384 @@
|
||||
# Architecture
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology | Notes |
|
||||
|-------|-----------|-------|
|
||||
| Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API |
|
||||
| Editor | Tiptap (ProseMirror) with custom slash-command extension | |
|
||||
| Backend | Python 3.12, Quart (async ASGI) | Serves both API and built frontend static files |
|
||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic | asyncpg driver |
|
||||
| LLM | Ollama (local) | Any OpenAI-compatible API also works |
|
||||
| Search | SearXNG (optional, self-hosted) | Web search + image search |
|
||||
| Push | Web Push / VAPID (pywebpush 2.x) | |
|
||||
| Deployment | Docker Compose | Single-container app + separate DB + LLM service |
|
||||
|
||||
## High-Level Component Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Docker Compose │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌────────────┐ │
|
||||
│ │ fabledassistant │ │ ollama │ │
|
||||
│ │ ┌────────────────┐ │ │ │ │
|
||||
│ │ │ Quart Server │ │ │ LLM API │ │
|
||||
│ │ │ ┌──────────┐ │ │ │ │ │
|
||||
│ │ │ │ Vue SPA │ │ │ └────────────┘ │
|
||||
│ │ │ │ (static) │ │ │ ▲ │
|
||||
│ │ │ └──────────┘ │ │ │ │
|
||||
│ │ │ ┌──────────┐ │ │ HTTP/REST │
|
||||
│ │ │ │ /api/* │──┼──┼─────────┘ │
|
||||
│ │ │ └──────────┘ │ │ │
|
||||
│ │ │ │ │ │ ┌────────────┐ │
|
||||
│ │ │ ▼ │ │ │ PostgreSQL │ │
|
||||
│ │ │ ┌──────────┐ │ │ │ 16 │ │
|
||||
│ │ │ │ asyncpg │──┼──┼──▶ │ │
|
||||
│ │ │ └──────────┘ │ │ └────────────┘ │
|
||||
│ │ └────────────────┘ │ │
|
||||
│ └──────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
fabledassistant/
|
||||
├── docker-compose.yml # Development stack
|
||||
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
||||
├── Dockerfile # Multi-stage build (Node → Python)
|
||||
├── alembic/ # Database migrations
|
||||
│ └── versions/ # Migration files (idempotent raw SQL)
|
||||
├── fable-mcp/ # Fable MCP server package
|
||||
│ └── fable_mcp/
|
||||
│ ├── server.py # FastMCP tool registrations
|
||||
│ ├── client.py # FableClient (httpx wrapper)
|
||||
│ └── tools/ # Tool modules (notes, tasks, projects, …)
|
||||
├── src/fabledassistant/
|
||||
│ ├── app.py # Quart app factory + blueprint registration
|
||||
│ ├── config.py # Config class (reads env vars)
|
||||
│ ├── auth.py # login_required decorator, session checks
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── routes/ # API blueprints (one file per resource)
|
||||
│ ├── services/ # Business logic (access, llm, tools, sharing, …)
|
||||
│ └── static/ # Built Vue SPA (generated at Docker build time)
|
||||
└── frontend/
|
||||
└── src/
|
||||
├── views/ # Page-level Vue components
|
||||
├── components/ # Reusable UI components
|
||||
├── composables/ # Vue composables (autosave, shortcuts, …)
|
||||
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
|
||||
└── api/ # Typed API client (client.ts)
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
**Single container for frontend + API.** Quart serves the Vue.js production build as static files and exposes the REST API under `/api/`. The SPA is built by Vite during the Docker image build.
|
||||
|
||||
**SPA routing via 404 handler.** `app.py` uses `@app.errorhandler(404)` (not a catch-all route) to serve static files or fall back to `index.html`. API routes (`/api/*`) always return JSON 404. This avoids a catch-all `/<path:path>` route intercepting API GETs.
|
||||
|
||||
**Unified note/task model.** A task is just a note with task attributes enabled. `status IS NOT NULL` means it's a task. "Convert to task" sets `status='todo'`; "convert to note" clears `status`, `priority`, `due_date`. No separate table, no cascade complexity.
|
||||
|
||||
**First-class tag column.** Tags live in a `tags ARRAY[text]` column and are explicitly set by the client — not auto-extracted from body text. Hierarchical tags (`project/webapp`) supported via SQL `unnest + LIKE` prefix matching.
|
||||
|
||||
**Background generation architecture.** LLM streaming runs in a detached `asyncio.Task` that writes into an in-memory `GenerationBuffer`. SSE clients tail the buffer and can reconnect mid-stream without data loss. Buffer has a `cancel_event` for user-initiated stop. Completed buffers are cleaned up after 60s grace period. Periodic DB flushes every 5s preserve partial content. Both chat and AI Assist use this architecture.
|
||||
|
||||
**SSE over WebSockets for LLM streaming.** SSE clients connect via `GET /api/chat/conversations/:id/generation/stream` with `Last-Event-ID` reconnection support. Frontend uses `fetch()` + `ReadableStream`.
|
||||
|
||||
**Context building is server-side.** Backend fetches URL content and searches notes. Frontend sends the message text + optional context note IDs. `build_context()` returns `(messages, context_meta)`; metadata includes auto-found note IDs/titles sent to frontend via a `context` SSE event before streaming begins.
|
||||
|
||||
**No blocking long-running operations.** Any slow operation (model pulls, LLM calls, URL fetching) must never block app startup or freeze the UI. Backend uses SSE streaming for incremental responses. Model pulls stream NDJSON progress to the frontend.
|
||||
|
||||
**SSRF protection.** `services/llm.py` blocks requests to loopback, private, link-local, reserved, and multicast addresses before fetching. `follow_redirects=False` prevents redirect-based bypasses.
|
||||
|
||||
**Session cookie security.** `HttpOnly` and `SameSite=Lax` always set. `Secure` flag controlled by `SECURE_COOKIES` env var.
|
||||
|
||||
**Rate limiting.** In-memory sliding-window rate limiter (`rate_limit.py`) applied to auth endpoints: login (10/60s), register (5/300s), forgot-password (5/300s), reset-password (10/60s). Keys are per-IP. `TRUST_PROXY_HEADERS` env var enables `X-Forwarded-For` / `X-Real-IP` when behind a reverse proxy.
|
||||
|
||||
**Idempotent migrations.** All Alembic migrations use raw SQL with `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Users
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | int PK | |
|
||||
| `username` | text UNIQUE NOT NULL | |
|
||||
| `email` | text nullable | |
|
||||
| `password_hash` | text nullable | NULL for OAuth-only accounts |
|
||||
| `oauth_sub` | text UNIQUE nullable | OIDC subject identifier |
|
||||
| `role` | text NOT NULL DEFAULT 'user' | First user auto-assigned 'admin' |
|
||||
| `session_version` | int NOT NULL DEFAULT 1 | Bumped on password change to evict sessions |
|
||||
| `created_at` | timestamptz | |
|
||||
|
||||
### Notes (unified — includes tasks)
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | int PK | |
|
||||
| `title` | text | |
|
||||
| `body` | text | Markdown |
|
||||
| `tags` | ARRAY[text] | GIN indexed; explicitly set by client |
|
||||
| `parent_id` | int FK self nullable | Sub-tasks / sub-notes |
|
||||
| `user_id` | int FK users nullable | CASCADE |
|
||||
| `project_id` | int FK projects nullable | |
|
||||
| `milestone_id` | int FK milestones nullable | |
|
||||
| `status` | text nullable | `todo`/`in_progress`/`done` — non-null = task |
|
||||
| `priority` | text nullable | `none`/`low`/`medium`/`high` |
|
||||
| `due_date` | date nullable | |
|
||||
| `created_at`, `updated_at` | timestamptz | |
|
||||
|
||||
Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
|
||||
|
||||
### Settings
|
||||
|
||||
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `briefing_enabled`, `briefing_locations`, `office_days`, etc.
|
||||
|
||||
### Conversations / Messages
|
||||
|
||||
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`briefing`/`mcp`), `briefing_date`, `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
|
||||
`messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`.
|
||||
|
||||
Title auto-generated by LLM on first exchange, re-generated every 10th message.
|
||||
|
||||
### Projects / Milestones
|
||||
|
||||
`projects`: `id`, `user_id`, `title`, `description`, `goal`, `status` (`active`/`completed`/`archived`), `color`, `auto_summary` (nullable text — LLM-generated summary for `search_projects` scoring), `summary_updated_at` (nullable timestamptz), timestamps.
|
||||
`milestones`: `id`, `user_id`, `project_id` FK CASCADE, `title`, `description`, `status`, `order_index`, timestamps.
|
||||
|
||||
### Sharing & Access
|
||||
|
||||
`project_shares`, `note_shares`: each has `shared_with_user_id` OR `shared_with_group_id` (exclusive), `permission` (`viewer`/`editor`/`admin`), `invited_by`.
|
||||
|
||||
`groups`, `group_memberships`: platform-wide groups with `member`/`owner` roles.
|
||||
|
||||
Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission.
|
||||
|
||||
### Briefing-Related Tables
|
||||
|
||||
`rss_feeds`: `id`, `user_id`, `url`, `name`, `category`, `last_fetched_at`.
|
||||
`rss_items`: `id`, `feed_id` FK, `guid`, `title`, `url`, `summary`, `pub_date`.
|
||||
`weather_cache`: per-user cache with `lat`, `lon`, `location_name`, `forecast_json`, `fetched_at`.
|
||||
|
||||
### API Keys
|
||||
|
||||
`api_keys`: `id`, `user_id` FK CASCADE, `prefix` (first 8 chars, displayed in UI), `key_hash` (SHA-256 of full key — full key never stored), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at`.
|
||||
|
||||
### App Logs
|
||||
|
||||
`category` (`audit`/`usage`/`error`), `user_id` FK nullable (SET NULL on delete), `username` (denormalised), `action`, `endpoint`, `method`, `status_code`, `duration_ms`, `error_type`, `error_message`, `traceback`, `details` JSONB.
|
||||
|
||||
## Detailed File Reference
|
||||
|
||||
### Backend (`src/fabledassistant/`)
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `app.py` | Quart app factory; SPA via 404 handler; JSON 404/500 for API; request logging; security headers in `after_request` |
|
||||
| `auth.py` | `login_required`, `admin_required`, `get_current_user_id` — shared `_check_auth()` helper; accepts session cookie or `Authorization: Bearer <key>` |
|
||||
| `config.py` | All config from env vars + Docker secret file support (`_read_secret`); `SECURE_COOKIES`, `TRUST_PROXY_HEADERS`, `OLLAMA_NUM_CTX`; `oidc_enabled()`, `searxng_enabled()` classmethods |
|
||||
| `rate_limit.py` | In-memory sliding-window rate limiter (`asyncio.Lock` + `defaultdict`); `is_rate_limited(key, max, window)` |
|
||||
| `models/note.py` | Unified Note model (notes + tasks) |
|
||||
| `models/user.py` | `id`, `username`, `email`, `password_hash` (nullable — NULL for OAuth-only), `oauth_sub` (unique nullable), `role`, `session_version` |
|
||||
| `models/conversation.py` | `Conversation` + `Message` models |
|
||||
| `models/app_log.py` | `AppLog` with `category`, denormalised `username`, `details` JSONB |
|
||||
| `models/api_key.py` | `ApiKey`: `id`, `user_id`, `prefix`, `key_hash` (SHA-256), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at` |
|
||||
| `models/event.py` | Internal events store (`Event` model: `id`, `user_id`, `title`, `description`, `start_dt`, `end_dt`, `all_day`, `location`, `caldav_uid` nullable, `color`, timestamps) |
|
||||
| `routes/api.py` | `/api` blueprint; `GET /api/health` (public) |
|
||||
| `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards |
|
||||
| `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) |
|
||||
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm; briefing conversation routes |
|
||||
| `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts |
|
||||
| `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) |
|
||||
| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
|
||||
| `routes/projects.py` | Projects CRUD + summary endpoint |
|
||||
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
|
||||
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
|
||||
| `routes/briefing.py` | Briefing conversation + reply + history; RSS feed management |
|
||||
| `routes/groups.py` | Group CRUD + membership management (admin) |
|
||||
| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares |
|
||||
| `routes/in_app_notifications.py` | In-app notification list + mark-read + count |
|
||||
| `routes/push.py` | Web Push subscription subscribe/unsubscribe; VAPID public key |
|
||||
| `routes/users.py` | User profile; admin user list + delete |
|
||||
| `routes/images.py` | Serve cached images at `/api/images/<id>` |
|
||||
| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download |
|
||||
| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) |
|
||||
| `routes/fable_mcp_dist.py` | `GET /api/fable-mcp/info` + `GET /api/fable-mcp/download` — package distribution |
|
||||
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
|
||||
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
|
||||
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
|
||||
| `services/oauth.py` | OIDC discovery (cached), PKCE auth URL, code exchange, `find_or_create_oauth_user` |
|
||||
| `services/api_keys.py` | `generate_key()`, `create_api_key()`, `list_api_keys()`, `revoke_api_key()`, `lookup_key()` (SHA-256 hash lookup) |
|
||||
| `services/llm.py` | `build_context()`, RAG injection, history summarisation, `stream_chat_with_tools()`, URL fetching, SSRF guard |
|
||||
| `services/generation_task.py` | `run_generation()` — full chat pipeline: intent routing, tool loop, SSE fan-out, push notification; `run_assist_generation()` |
|
||||
| `services/intent.py` | `classify_intent()` — fast non-streaming LLM call; intent skip heuristic; `_PRIOR_WORK_REFS` fast-path |
|
||||
| `services/tools.py` | All LLM tool definitions + `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` dispatcher; duplicate guards; `_resolve_project()` 4-step lookup; `search_projects` and `set_rag_scope` tools |
|
||||
| `services/projects.py` | Project CRUD + `generate_project_summary()` (Ollama, fire-and-forget) + `backfill_project_summaries()` (startup) |
|
||||
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes(orphan_only=False)` (pgvector cosine similarity) |
|
||||
| `services/generation_buffer.py` | In-memory SSE event buffer; `cancel_event`; 60s cleanup; supports both chat (int keys) and assist (string keys) |
|
||||
| `services/notes.py` | Note CRUD, wikilink resolution, backlink queries, tag management |
|
||||
| `services/note_versions.py` | Version snapshot on save; restore-from-version; diff metadata |
|
||||
| `services/note_drafts.py` | Per-user per-note draft persistence (AI Assist pending state) |
|
||||
| `services/settings.py` | `get_setting()`, `set_setting()`, `get_all_settings()` — key-value per user |
|
||||
| `services/tag_suggestions.py` | `/api/notes/suggest-tags` — LLM-generated tag suggestions for note body |
|
||||
| `services/access.py` | Permission resolution for all shared resources |
|
||||
| `services/sharing.py` | Create/revoke shares; list shares; `get_shared_with_me()` |
|
||||
| `services/groups.py` | Group CRUD; membership management |
|
||||
| `services/notifications.py` | Create/read/mark-read in-app notifications |
|
||||
| `services/task_logs.py` | Append/list/delete work log entries on tasks |
|
||||
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → `GenerationBuffer` stream |
|
||||
| `services/briefing_scheduler.py` | APScheduler `BackgroundScheduler`; slots with catch-up logic; async-safe via `asyncio.create_task` |
|
||||
| `services/briefing_conversations.py` | Briefing conversation persistence and history queries |
|
||||
| `services/briefing_profile.py` | Per-user profile note that the assistant updates over time |
|
||||
| `services/research.py` | SearXNG research pipeline: 5 sub-queries → parallel fetch → synthesis; `search_images` for image category |
|
||||
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
|
||||
| `routes/events.py` | `/api/events` — event CRUD routes |
|
||||
| `services/caldav.py` | Optional CalDAV sync — user-configured external server; syncs to/from internal store via `caldav_uid` FK; `is_caldav_configured()` guards tool activation |
|
||||
| `services/calendar_sync.py` | Dead code — Radicale sync service; was trialled and removed |
|
||||
| `services/images.py` | `fetch_and_store_image()` — SHA-256 dedup, content-type validation, 5 MB cap |
|
||||
| `services/backup.py` | `export_full_backup()`, `export_user_backup()`, `restore_full_backup()` (version 2 with ID maps) |
|
||||
| `services/push.py` | VAPID key auto-generation; `send_push_notification()` fire-and-forget; 410 Gone cleanup |
|
||||
| `services/logging.py` | `log_audit`, `log_usage`, `log_error`, `start_log_retention_loop` (hourly cleanup) |
|
||||
| `services/email.py` | SMTP email sending for password reset; reads `SMTP_*` env vars |
|
||||
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast + per-user DB cache |
|
||||
| `services/rss.py` | feedparser fetch; per-feed DB cache; prune-to-100 items |
|
||||
| `services/assist.py` | `build_assist_messages()` — section/whole-doc modes; project context injection |
|
||||
|
||||
### Frontend (`frontend/src/`)
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `App.vue` | App shell (`100dvh` flex column); global keyboard shortcuts (`g`+key nav, `n/t/c/e///?`); starts status polling + loads settings on mount |
|
||||
| `api/client.ts` | `ApiError`, `apiGet/Post/Put/Patch/Delete`, `apiSSEStream` (fetch + ReadableStream), auto 401→login redirect |
|
||||
| `stores/auth.ts` | User, `isAuthenticated`, `isAdmin`, `oauthEnabled`, `localAuthEnabled`, login/register/logout/checkAuth |
|
||||
| `stores/chat.ts` | Conversation CRUD; `sendMessage()` SSE streaming; `reconnectIfGenerating()`; message queue (localStorage); `streamingStatus` |
|
||||
| `stores/notes.ts` | CRUD + tag filter; `resolveTitle`; `convertToTask/Note`; `fetchBacklinks`; `fetchAllTags` |
|
||||
| `stores/tasks.ts` | CRUD + status/priority filter; `patchStatus` |
|
||||
| `stores/settings.ts` | `assistantName`, `defaultModel`, `installedModels`; `pullModel()`, `deleteModel()` |
|
||||
| `stores/push.ts` | `isSupported`, `permission`, `isSubscribed`, `subscribe/unsubscribe` |
|
||||
| `stores/notifications.ts` | `count`, `items`, `fetchCount`, `fetchAll`, `markRead`, `markAll` |
|
||||
| `views/HomeView.vue` | Chat-first dashboard; 6 task sections (Overdue → Other); 8 recent notes; inline streaming response |
|
||||
| `views/ChatView.vue` | `/chat` — SSE streaming; context sidebar (Suggested/In Context); note picker; scope chip (RAG project scope pill above input); message queue; bulk delete |
|
||||
| `views/CalendarView.vue` | `/calendar` — FullCalendar v6 month/week/day views; click to create event; click event to open EventSlideOver |
|
||||
| `components/EventSlideOver.vue` | Reusable slide-over for event create/edit/delete; used in ToolCallCard, HomeView, CalendarView |
|
||||
| `views/WorkspaceView.vue` | `/workspace/:id` — 3-panel (tasks/chat/notes); SSE tool-call watcher; conv persisted to localStorage |
|
||||
| `views/GraphView.vue` | `/graph` — D3 force-directed; tag/note/project-hub nodes; physics panel; peek panel |
|
||||
| `views/ProjectView.vue` | Kanban grouped by milestone; advance buttons; milestone management |
|
||||
| `views/SettingsView.vue` | Tabbed settings (11 tabs); tab state in localStorage |
|
||||
| `views/NoteEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; diff view; version history; autosave |
|
||||
| `views/TaskEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; task log section; sub-tasks |
|
||||
| `components/ToolCallCard.vue` | Tool call results; `requires_confirmation` inline confirm/deny; direct POST on "Create anyway" |
|
||||
| `components/ChatMessage.vue` | Message bubble; markdown rendering; tool call cards; "Save as Note" |
|
||||
| `components/TagInput.vue` | Chip-based tag input; Enter/comma to confirm; autocomplete from `/api/notes/tags` |
|
||||
| `components/TiptapEditor.vue` | Tiptap wrapper; markdown↔HTML round-trip; selection change emit; WikilinkDecoration; TagDecoration |
|
||||
| `components/ShareDialog.vue` | `<Teleport>` modal; user tab + group tab; current shares list |
|
||||
| `components/WorkspaceTaskPanel.vue` | Milestone-grouped task list; detail slide-over |
|
||||
| `components/WorkspaceNoteEditor.vue` | List ↔ TipTap editor with autosave |
|
||||
| `composables/useAssist.ts` | AI assist: section parsing, SSE streaming, accept/reject, LCS diff, persistent draft |
|
||||
| `composables/useAutoSave.ts` | Interval-based autosave (5 min) with dirty/saving guards |
|
||||
| `composables/useEditorGuards.ts` | Ctrl+S, `beforeunload` warning, route-leave confirm |
|
||||
| `composables/useTagSuggestions.ts` | `/api/notes/suggest-tags` call + suggestion state |
|
||||
| `composables/useListKeyboardNavigation.ts` | j/k/Enter list navigation; focus-in-input guard |
|
||||
| `extensions/WikilinkDecoration.ts` | ProseMirror decoration plugin highlighting `[[wikilinks]]` |
|
||||
| `extensions/TagDecoration.ts` | ProseMirror decoration plugin highlighting `#tags` |
|
||||
| `extensions/WikilinkSuggestion.ts` | `@tiptap/suggestion` extension for `[[` autocomplete |
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Responsibility |
|
||||
|---------|---------------|
|
||||
| `services/access.py` | Permission resolution for all shared resources |
|
||||
| `services/llm.py` | `build_context()`, RAG injection, history summarisation |
|
||||
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
|
||||
| `services/tools.py` | All LLM tool implementations (`create_note`, `search_notes`, `get_weather`, …) |
|
||||
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
|
||||
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → briefing output |
|
||||
| `services/briefing_scheduler.py` | APScheduler integration, catch-up logic for missed slots |
|
||||
| `services/backup.py` | Full and per-user backup export/restore (version 2 format) |
|
||||
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
|
||||
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
|
||||
|
||||
## Authentication
|
||||
|
||||
**Session cookies** — `HttpOnly`, `SameSite=Lax`, optionally `Secure` (`SECURE_COOKIES` env var). Session includes `session_version`; mismatch with DB value (after password change) results in 401 and session clear.
|
||||
|
||||
**Bearer token / API keys** — `Authorization: Bearer <key>` accepted by `_check_auth()` as an alternative to session cookies. The raw key is SHA-256 hashed and looked up via `services/api_keys.py`. Keys with `scope=read` are rejected on non-safe methods (`POST`/`PATCH`/`DELETE`). Used by Fable MCP and any external API consumers.
|
||||
|
||||
**Local auth + OIDC/OAuth (PKCE)** — `services/oauth.py` handles discovery and `find_or_create_oauth_user`. On OAuth login: checks existing `oauth_sub` → matching email → creates new user.
|
||||
|
||||
See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions.
|
||||
|
||||
## LLM Pipeline Internals
|
||||
|
||||
### Intent Routing
|
||||
|
||||
Before the main model runs, a lightweight intent classifier (`services/intent.py`) runs concurrently with `build_context()`. It makes a fast non-streaming call using a smaller dedicated model (`OLLAMA_INTENT_MODEL`, default `qwen2.5:7b`) to determine if the message requires a tool call.
|
||||
|
||||
**Skip heuristic** — Intent classification is skipped entirely for short messages (≤10 words) with no action/object keywords, saving 400–800ms on conversational replies.
|
||||
|
||||
**Prior-work fast-path** — `_PRIOR_WORK_REFS` regex detects phrases like "research you did", "note you made", "using your research" and returns no-tool immediately, preventing `search_web` from firing when the user references existing notes.
|
||||
|
||||
If a tool is detected, the intent's one-sentence `ack` field is streamed as the first chunk (TTFT), the tool executes, then the main model generates a follow-up with the tool result. For chat-only responses the main model streams directly.
|
||||
|
||||
### Tool Loop
|
||||
|
||||
Multi-round tool loop (max 5 rounds). All implementations in `services/tools.py`; `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` is the dispatcher. `conv_id` and `workspace_project_id` are threaded in from `run_generation()` so tools like `set_rag_scope` can write to the current conversation.
|
||||
|
||||
**Duplicate protection on `create_note` / `create_task`:**
|
||||
1. Exact title match (case-insensitive) → hard block, redirect to `update_note`
|
||||
2. Fuzzy title match (SequenceMatcher ≥ 82%; punctuation stripped before candidate search) → hard block
|
||||
3. Semantic content similarity (threshold 0.90, body ≥ 200 chars) → soft block with `requires_confirmation: true`
|
||||
|
||||
**Project resolution** (`_resolve_project`): 4-step lookup — (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55.
|
||||
|
||||
### Context Window and Summarisation
|
||||
|
||||
`OLLAMA_NUM_CTX` (default 16384) controls the context window for all generation calls. Intent classification always uses `num_ctx=4096` to reduce VRAM pressure.
|
||||
|
||||
History summarisation threshold: 30 messages. Keeps 8 recent messages. Summary max 400 tokens.
|
||||
|
||||
### Web Research Pipeline
|
||||
|
||||
`services/research.py` implements a full autonomous research pipeline:
|
||||
1. Intent model generates 5 focused sub-queries
|
||||
2. All 5 SearXNG queries run in parallel (200ms stagger to avoid rate limiter)
|
||||
3. Up to 15 unique URLs fetched in parallel
|
||||
4. Up to 12 sources passed to synthesis LLM
|
||||
5. Result saved as a note with `tags=["research"]`
|
||||
|
||||
SearXNG tip: add the app server IP to `botdetection.ip_lists.pass_ip` in SearXNG `settings.yml` to bypass the rate limiter for trusted backend requests.
|
||||
|
||||
### Image Cache
|
||||
|
||||
`search_images` tool fetches images server-side via SearXNG, stores them on disk (SHA-256 dedup, content-type validation, 5 MB cap), and serves from `/api/images/<id>`. The user's browser never contacts the original image host.
|
||||
|
||||
Config: `IMAGE_CACHE_DIR` (default `/data/images`), `IMAGE_MAX_BYTES` (default 5 MB).
|
||||
|
||||
## RAG Pipeline
|
||||
|
||||
1. `semantic_search_notes()` — cosine similarity via pgvector, threshold configurable per call; accepts `orphan_only` and `project_id` scope flags.
|
||||
2. Notes ≥ 0.60 similarity auto-injected into system prompt (up to 3, 800 chars each).
|
||||
3. Notes 0.45–0.60 surfaced in chat sidebar as "Suggested" (user clicks to include).
|
||||
4. Explicitly included notes delivered full-body.
|
||||
5. `excluded_note_ids` prevents the current note from being injected as its own context.
|
||||
|
||||
### RAG Scope (three-value system)
|
||||
|
||||
`conversations.rag_project_id` controls which notes are eligible for retrieval:
|
||||
|
||||
| Value | Behaviour |
|
||||
|-------|-----------|
|
||||
| `NULL` (default) | Orphan notes only — notes with `project_id IS NULL` |
|
||||
| `-1` | All notes — opt-in to global search |
|
||||
| Positive int | That project's notes only |
|
||||
|
||||
`build_context()` derives `orphan_only` + `effective_project_id` from this value before calling both the semantic and keyword search paths.
|
||||
|
||||
**Scope tools** — Two LLM tools let the model discover and switch scope mid-conversation:
|
||||
- `search_projects` — SequenceMatcher scoring over title + description + `auto_summary`; returns top 5 matching projects.
|
||||
- `set_rag_scope` — persists the new `rag_project_id` to DB immediately; blocked in workspace view; causes the SSE `done` event to include `new_rag_scope` + `new_rag_scope_label` so the frontend chip updates reactively.
|
||||
|
||||
**Project summaries** — `generate_project_summary()` calls Ollama (fire-and-forget) and stores the result in `projects.auto_summary`. Triggered on project update and note saves (debounced 1h). `backfill_project_summaries()` runs at startup.
|
||||
|
||||
Embedding model: `nomic-embed-text` via Ollama. Backfill runs 30s after startup (background task).
|
||||
@@ -0,0 +1,155 @@
|
||||
# Configuration
|
||||
|
||||
Configuration is via environment variables. The `docker-compose.yml` file sets defaults for local development; override them in a `.env` file (gitignored).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Core
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/fabledassistant` | PostgreSQL async connection string |
|
||||
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
|
||||
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
|
||||
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
||||
| `SECURE_COOKIES` | `false` | Set `true` when running behind TLS |
|
||||
| `BASE_URL` | — | Public URL (e.g. `https://notes.example.com`) — required for OIDC redirect URIs and email links |
|
||||
|
||||
### LLM / Ollama
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OLLAMA_URL` | `http://ollama:11434` | Ollama API base URL |
|
||||
| `OLLAMA_MODEL` | `llama3.2` | Default LLM model (used as fallback; per-user setting overrides this) |
|
||||
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG embeddings |
|
||||
| `OLLAMA_NUM_CTX` | `16384` | Context window size passed to Ollama for all generation calls |
|
||||
|
||||
### Authentication / OIDC
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local username/password login (SSO-only mode) |
|
||||
| `OIDC_ISSUER` | — | OIDC issuer URL (e.g. `https://auth.example.com/application/o/fabled/`) |
|
||||
| `OIDC_CLIENT_ID` | — | OIDC client ID |
|
||||
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
|
||||
| `OIDC_CLIENT_SECRET_FILE` | — | Docker secret file alternative to `OIDC_CLIENT_SECRET` |
|
||||
| `OIDC_SCOPES` | `openid profile email` | Space-separated OIDC scopes to request |
|
||||
|
||||
See [sso-oauth.md](sso-oauth.md) for provider-specific setup.
|
||||
|
||||
### Security / Proxy
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TRUST_PROXY_HEADERS` | `false` | Set `true` when behind a trusted reverse proxy to read real IP from `X-Forwarded-For` / `X-Real-IP` |
|
||||
|
||||
### Web Search / Images
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SEARXNG_URL` | — | SearXNG base URL for web search and image search tools |
|
||||
| `IMAGE_CACHE_DIR` | `/data/images` | Directory for cached search images |
|
||||
| `IMAGE_MAX_BYTES` | `5242880` (5 MB) | Maximum size for cached images |
|
||||
|
||||
### Data / Logging
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
|
||||
| `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) |
|
||||
|
||||
### Fable MCP Distribution
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `FABLE_MCP_DIST_DIR` | `/app/dist` | Directory where the bundled `fable-mcp` wheel is placed at build time |
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
### Development (`docker-compose.yml`)
|
||||
|
||||
```bash
|
||||
# Copy the example env file
|
||||
cp .env.example .env
|
||||
# Edit .env to set a real SECRET_KEY
|
||||
|
||||
# Start the stack
|
||||
docker compose up --build
|
||||
|
||||
# App available at http://localhost:5000
|
||||
```
|
||||
|
||||
The first user to register becomes admin. Registration auto-closes after that (re-enable from Settings → Users).
|
||||
|
||||
### Production (`docker-compose.prod.yml`)
|
||||
|
||||
The production compose file adds:
|
||||
- Docker Secrets for `SECRET_KEY_FILE` and `DATABASE_URL_FILE`
|
||||
- Network isolation (internal bridge for DB + Ollama; only the app is externally accessible)
|
||||
- Health checks and resource limits
|
||||
|
||||
```bash
|
||||
# Create Docker secrets
|
||||
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
|
||||
echo "postgresql+asyncpg://fabled:strongpassword@db/fabledassistant" | docker secret create fabled_db_url -
|
||||
|
||||
# Deploy
|
||||
docker stack deploy -c docker-compose.prod.yml fabled
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Reverse Proxy (Required)
|
||||
|
||||
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
|
||||
|
||||
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
|
||||
- Terminate TLS at the proxy; forward to port 5000
|
||||
- **Do not expose port 5000 directly to the internet**
|
||||
- Rate-limit auth endpoints: ≤ 5 req/min per IP on `/api/auth/login` and `/api/auth/register`
|
||||
|
||||
Example Nginx location block:
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Required for SSE streaming
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
```
|
||||
|
||||
If using `TRUST_PROXY_HEADERS=true`, ensure your proxy strips any client-supplied `X-Forwarded-For` headers before adding its own.
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- **Strong `SECRET_KEY`** — generate with:
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
Or use Docker Secrets via `SECRET_KEY_FILE`.
|
||||
- **`SECURE_COOKIES=true`** — must be set when running behind TLS.
|
||||
- **`BASE_URL`** — set to your public URL; required for OIDC redirects and email links.
|
||||
- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links.
|
||||
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions. Button also available in Settings → Account.
|
||||
- **Keep Ollama on an internal network** — both compose files keep Ollama off the host network. Never expose the Ollama port publicly.
|
||||
- **Default SECRET_KEY warning** — the app logs a `WARNING` on startup if the default dev key is in use.
|
||||
|
||||
## Model Management
|
||||
|
||||
Models are managed through Settings → General → Model Management in the web UI. You can:
|
||||
- Pull new models by name (streams progress via SSE)
|
||||
- View installed models with size and loaded/unloaded state
|
||||
- Delete unused models
|
||||
|
||||
The app auto-warms user-preferred models on startup (only models already installed — never auto-pulls). The embedding model (`nomic-embed-text`) is auto-pulled on startup if missing.
|
||||
|
||||
Recommended models for 2× 8 GB GPU:
|
||||
- **`qwen3:8b`** — strong reasoning + tools, fits in 8 GB, supports thinking mode
|
||||
- **`qwen2.5:7b`** — fast, tool-capable, smaller context
|
||||
- **`llama3.1:8b`** — reliable baseline, widely tested with tools
|
||||
@@ -0,0 +1,173 @@
|
||||
# Development
|
||||
|
||||
## Workflow
|
||||
|
||||
All development is Docker-based. Do not install Python or Node dependencies locally.
|
||||
|
||||
```bash
|
||||
# Start the full stack (app + PostgreSQL + Ollama)
|
||||
docker compose up --build
|
||||
|
||||
# Rebuild after backend changes (frontend changes require rebuild too)
|
||||
docker compose up --build app
|
||||
|
||||
# Reset everything (wipes database)
|
||||
docker compose down -v && docker compose up --build
|
||||
|
||||
# Run checks (lint, format, typecheck, tests)
|
||||
make check
|
||||
|
||||
# Individual checks
|
||||
make lint # ruff check src/
|
||||
make fmt # ruff format src/
|
||||
make typecheck # vue-tsc --noEmit
|
||||
make test # pytest tests/
|
||||
```
|
||||
|
||||
## Frontend Hot Reload
|
||||
|
||||
The Docker setup does not include Vite's hot-reload dev server. After frontend changes, rebuild the image. For faster iteration during active frontend work, you can run Vite locally:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Vite dev server at http://localhost:5173
|
||||
```
|
||||
|
||||
Point the Vite dev server at the backend by setting `VITE_API_BASE_URL=http://localhost:5000` (or configure `vite.config.ts` proxy).
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Alembic migrations run automatically on container startup (`alembic upgrade head` in the `CMD`).
|
||||
|
||||
To create a new migration:
|
||||
```bash
|
||||
# Inside the running app container
|
||||
docker compose exec app alembic revision -m "description_of_change"
|
||||
# Edit the generated file in alembic/versions/
|
||||
```
|
||||
|
||||
Migration conventions:
|
||||
- Use raw SQL with `IF NOT EXISTS` guards for idempotency
|
||||
- Use `DO $$ BEGIN CREATE TYPE … EXCEPTION WHEN duplicate_object THEN NULL; END $$` for enum types
|
||||
- Number migrations sequentially (e.g. `0027_add_something.py`)
|
||||
- Always provide both `upgrade()` and `downgrade()`
|
||||
|
||||
## CI/CD
|
||||
|
||||
### Pipeline
|
||||
|
||||
CI runs on Forgejo Actions with a custom runner base image (`py3.12-node22`):
|
||||
|
||||
| Trigger | Jobs | Docker tags pushed |
|
||||
|---------|------|--------------------|
|
||||
| Push to `dev` | typecheck + lint + test → build | `:dev`, `:<sha>` |
|
||||
| Tag `v*` on `main` | typecheck + lint + test → build | `:latest`, `:<version>`, `:<sha>` |
|
||||
| Push to `main` | typecheck + lint + test | (no build) |
|
||||
|
||||
### Release Process
|
||||
|
||||
1. Work on `dev` branch — CI validates on every push
|
||||
2. When ready, open a PR from `dev` → `main` in Forgejo
|
||||
3. Merge the PR
|
||||
4. Create a release via the Forgejo UI on `main` with a `v*` tag (e.g. `v26.03.23.1` — CalVer: `YY.MM.DD.N`)
|
||||
5. The tag push triggers CI → build job pushes `:latest` + `:<version>` Docker images
|
||||
6. After merging to main, sync dev back:
|
||||
```bash
|
||||
git checkout dev && git merge main && git push origin dev
|
||||
```
|
||||
|
||||
### Custom Runner
|
||||
|
||||
Runner base image: `infra/Dockerfile.runner-base` (Ubuntu 24.04 + Python 3.12 + Node 22 LTS).
|
||||
Runner config: `infra/act-runner-config.yml` (label: `py3.12-node22`).
|
||||
Runner compose: `infra/runner-compose.yml`.
|
||||
|
||||
To activate a new runner registration, copy `infra/act-runner-config.yml` to the runner's config directory, delete the `.runner` registration file in the runner container, and restart the stack.
|
||||
|
||||
### Docker Registry
|
||||
|
||||
Images pushed to: `git.fabledsword.com/bvandeusen/fabledassistant`
|
||||
Cache tag: `:cache` (reduces build time ~80%)
|
||||
|
||||
Required secrets (repo → Settings → Secrets → Actions):
|
||||
- `REGISTRY_USER` — Forgejo username
|
||||
- `REGISTRY_TOKEN` — Forgejo PAT with `write:packages` scope
|
||||
|
||||
## Migration Chain
|
||||
|
||||
Current migration sequence (all idempotent raw SQL):
|
||||
|
||||
```
|
||||
0001 create_notes_table
|
||||
0002 create_tasks_table
|
||||
0003 task_note_companion (data migration)
|
||||
0004 merge_tasks_into_notes
|
||||
0005 add_chat_tables
|
||||
0006 add_settings_table
|
||||
0007 add_title_and_updated_at_indexes
|
||||
0008 add_users_and_user_id
|
||||
0009 add_message_status
|
||||
0010 add_app_logs_table
|
||||
0011 add_password_reset_tokens
|
||||
0012 add_invitation_tokens
|
||||
0013 add_tool_calls_to_messages
|
||||
0014 add_note_embeddings
|
||||
0015 add_oauth_fields
|
||||
0016 add_image_cache
|
||||
0017 add_projects
|
||||
0018 add_push_subscriptions
|
||||
0019 add_events (dead code — internal CalDAV/Radicale table; Radicale was removed)
|
||||
0020 add_milestones
|
||||
0021 add_task_logs
|
||||
0022 add_note_versions_and_drafts
|
||||
0023 add_tags_to_note_versions
|
||||
0024 add_session_version
|
||||
0025 add_sharing_and_notifications
|
||||
0026 add_briefing_tables
|
||||
0027 add_api_keys
|
||||
```
|
||||
|
||||
**Important:** Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run. Always use raw SQL with `IF NOT EXISTS` / `DO $$ BEGIN ... EXCEPTION WHEN duplicate_object` guards.
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Backend
|
||||
|
||||
- Services: `async with async_session() as session:` — import from `fabledassistant.models`
|
||||
- No `fabledassistant.database` module
|
||||
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
|
||||
- All business logic in `services/`; routes are thin wrappers
|
||||
- Permission checks via `services/access.py` — never inline ownership checks in routes
|
||||
|
||||
### Frontend
|
||||
|
||||
- API calls via `frontend/src/api/client.ts` typed helpers (`apiGet`, `apiPost`, `apiPatch`, `apiDelete`)
|
||||
- Pinia stores for shared state; local `ref()` for component-only state
|
||||
- Composables in `composables/` for reusable behaviour (autosave, keyboard nav, tag suggestions, …)
|
||||
- Views are page-level components in `views/`; reusable UI in `components/`
|
||||
|
||||
### Commit Style
|
||||
|
||||
```
|
||||
type(scope): short description
|
||||
|
||||
Longer body if needed.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
|
||||
Scopes: feature area (e.g. `chat`, `briefing`, `fable-mcp`, `notes`)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test
|
||||
|
||||
# Run specific test file
|
||||
docker compose exec app /opt/venv/bin/pytest tests/test_auth.py -v
|
||||
```
|
||||
|
||||
Tests are in `tests/`. They run against a real PostgreSQL instance in CI (not mocked). Keep tests integration-style where possible — mock failures have historically masked real migration bugs.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Features
|
||||
|
||||
## Notes
|
||||
|
||||
Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks.
|
||||
|
||||
**Wikilinks** — Link notes with `[[Title]]` or `[[Title|Display Text]]` syntax. Clicking a wikilink navigates to (or auto-creates) the referenced note. The editor suggests existing note titles as candidate links while typing `[[`. Backlinks appear in the note viewer sidebar.
|
||||
|
||||
**Tags** — First-class `ARRAY[text]` column. Tag autocomplete in the editor sidebar suggests existing tags. Hierarchical tags (`project/webapp`) supported — filtering by `project` matches all `project/*` children. Tags are browsable via the knowledge graph.
|
||||
|
||||
**Version history** — Every body edit snapshots a version (up to 20 per note). Browse and restore from the editor's History panel. Diff view shows changes against the current body.
|
||||
|
||||
**AI writing assist** — Select a passage or work on the full document. Give an instruction ("make this more concise", "add examples"). The assistant streams a proposal; a diff view shows changes to accept or reject. Drafts persist across page loads.
|
||||
|
||||
**Link suggestions** — The editor detects note titles appearing as plain text in the body and suggests converting them to wikilinks.
|
||||
|
||||
## Tasks
|
||||
|
||||
Tasks carry status (`todo` → `in_progress` → `done`), priority (`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task (sub-tasks).
|
||||
|
||||
**Task work logs** — Append progress log entries to a task with optional duration. Time tracking is visible in the task editor sidebar.
|
||||
|
||||
**Sub-tasks** — Any task can have child tasks via `parent_id`. The task viewer shows sub-tasks inline.
|
||||
|
||||
**Convert freely** — Convert a note to a task (sets `status=todo`) or a task back to a note from the viewer toolbar.
|
||||
|
||||
## Projects and Milestones
|
||||
|
||||
**Projects** — Group related notes and tasks. Each project has a title, description, goal, status (`active`/`completed`/`archived`), and a colour.
|
||||
|
||||
**Milestones** — Ordered stages within a project. Tasks are assigned to milestones. Milestone completion percentage shown on the project page.
|
||||
|
||||
**Kanban view** — `/projects/:id` groups tasks by milestone in a kanban-style column layout with status-advance buttons directly on cards (→ advance, ✓ complete).
|
||||
|
||||
**Project Workspace** — `/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically after tool calls.
|
||||
|
||||
## Knowledge Graph
|
||||
|
||||
`/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; invisible project hub nodes attract project members. Physics controls: repulsion, link distance, link strength, hub pull, gravity. Click any node to open a slide-in peek panel. Click a tag node to filter the notes list.
|
||||
|
||||
## AI Chat
|
||||
|
||||
Full conversation history with SSE streaming. Features:
|
||||
- **RAG** — Semantically relevant notes (≥ 0.60 cosine similarity) auto-injected as context. Notes 0.45–0.60 shown in sidebar as "Suggested."
|
||||
- **Attach notes** — Paperclip icon to include specific notes in context.
|
||||
- **RAG scope chip** — Pill above the input bar shows the current note scope. Click to switch: "Orphan notes only" (default — project notes stay out of general chat), any active project, or "All notes." Scope is persisted per conversation. The AI can also call `search_projects` and `set_rag_scope` mid-conversation to switch scope automatically; the chip pulses when this happens.
|
||||
- **Tool calls** — The assistant can create/update notes, tasks, projects, milestones, search the web, check weather, read RSS, query calendar events, and more. Tool calls display inline with confirm/deny for creates.
|
||||
- **Thinking mode** — Toggle extended reasoning for complex questions.
|
||||
- **Abort** — Stop button cancels in-flight generation.
|
||||
- **Message queue** — Messages sent while generation is in progress are queued and drained sequentially.
|
||||
- **Save to note** — Save any assistant reply directly as a note.
|
||||
- **Bulk delete** — Select and delete multiple conversations.
|
||||
- **Retention** — Conversations auto-pruned after configurable days (default 90).
|
||||
|
||||
## Daily Briefing
|
||||
|
||||
`/briefing` is a scheduled, dialogue-based morning briefing. The assistant compiles tasks, calendar events, projects, weather forecast, and RSS digest at configurable times, then checks in throughout the day. You can reply interactively.
|
||||
|
||||
**Schedule** — Configurable slots: morning (default 4am compile), midday (8am check-in), evening (12pm check-in), night (4pm). Scheduler catches up missed slots on startup.
|
||||
|
||||
**Configuration** — Settings → Briefing: enable toggle, location geocoding, office days, time slot toggles, RSS feed management, push notification toggle.
|
||||
|
||||
**RSS feeds** — Add feed URLs with optional name and category. Feeds are fetched and cached; the briefing digest includes recent items. Category badges shown in the UI. Feeds can be manually refreshed.
|
||||
|
||||
**Weather** — Location-based forecast via Open-Meteo. Multiple locations supported (home, work, or any city name). Geocoding via Nominatim.
|
||||
|
||||
**Profile note** — The assistant maintains a profile note for each user that it updates based on briefing conversations, improving personalisation over time.
|
||||
|
||||
## Web Research
|
||||
|
||||
The assistant can search the web (SearXNG) and fetch pages, synthesising findings into notes. A lightweight `search_web` tool answers quick questions inline without saving. Requires `SEARXNG_URL` to be configured.
|
||||
|
||||
## Calendar
|
||||
|
||||
`/calendar` shows a full FullCalendar view (month, week, day). Click an empty slot to create an event; click an existing event to edit or delete it via a slide-over panel.
|
||||
|
||||
**Internal events store** — Events are stored in the app database (`events` table), making them available without any external calendar. Fields: title, description, start/end datetime, all-day toggle, location, colour.
|
||||
|
||||
**AI tools** — `create_event`, `list_events`, `search_events`, `update_event`, `delete_event` all operate on the internal store. Tool-call result cards in chat are clickable and open the same EventSlideOver for editing.
|
||||
|
||||
**HomeView widget** — The dashboard shows today's and the next 7 days' events as clickable cards above the hero project.
|
||||
|
||||
**CalDAV sync (optional)** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) in Settings → Integrations. Events sync bidirectionally via a `caldav_uid` field.
|
||||
|
||||
## Sharing and Collaboration
|
||||
|
||||
**Share** — Share any project or note/task with users or groups at `viewer`/`editor`/`admin` permission levels. Share button in the viewer/project toolbar opens a dialog.
|
||||
|
||||
**Groups** — Admins create platform-wide groups and assign users `member`/`owner` roles. Share a resource with a group in one action.
|
||||
|
||||
**Shared with me** — `/shared` lists all incoming shared projects and notes with permission badges.
|
||||
|
||||
**Notifications** — Bell icon in nav shows unread count (60s polling). Notifications generated for: project shared, note shared, added to group. Click navigates to the resource.
|
||||
|
||||
**Push notifications** — Web Push (VAPID) notifies when AI generation completes, even in another tab. Works over HTTPS only. Configurable per-user.
|
||||
|
||||
## Quick Capture
|
||||
|
||||
Quick capture from the Android app routes to the intent classifier. It creates notes, tasks, or projects based on content — using the user's configured model, not the hardcoded default.
|
||||
|
||||
## Data Export and Backup
|
||||
|
||||
- **Personal export** — Settings → Data: download all notes/tasks as a Markdown ZIP (with YAML frontmatter) or JSON array.
|
||||
- **Admin backup** — Full application backup (version 2): includes projects, milestones, task logs, AI drafts, note versions, push subscriptions. ID remapping on restore for cross-instance migration.
|
||||
|
||||
## PWA
|
||||
|
||||
Installable as a desktop or mobile app. Service worker caches the shell; push notifications are suppressed when the relevant tab is already focused. Works over HTTPS only in Firefox.
|
||||
|
||||
## Settings
|
||||
|
||||
Settings are tabbed:
|
||||
|
||||
| Tab | Contents |
|
||||
|-----|----------|
|
||||
| General | Assistant name, default model, model management (pull/delete) |
|
||||
| Account | Email change, password change, session invalidation |
|
||||
| Notifications | Push notification subscription, briefing push toggle |
|
||||
| Integrations | CalDAV configuration, SearXNG status |
|
||||
| Data | Personal export, backup/restore (admin) |
|
||||
| Briefing | Enable, location, office days, slots, RSS feeds, weather |
|
||||
| API Keys | Create/revoke API keys, Fable MCP download and install |
|
||||
| Config (admin) | Base URL, SMTP, OIDC settings |
|
||||
| Users (admin) | User list, invite links, registration toggle |
|
||||
| Logs (admin) | Error, audit, and usage logs with search |
|
||||
| Groups (admin) | Create/manage groups and membership |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Email integration (read/send via IMAP/SMTP tools in chat)
|
||||
- Session invalidation on user deletion
|
||||
- Flutter push notifications (requires FCM/APNs — separate from web VAPID)
|
||||
- Flutter milestone support in project view
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `g` + `h` | Go to Home |
|
||||
| `g` + `n` | Go to Notes |
|
||||
| `g` + `t` | Go to Tasks |
|
||||
| `g` + `p` | Go to Projects |
|
||||
| `g` + `c` | Go to Chat |
|
||||
| `g` (bare) | Go to Graph |
|
||||
| `n` | New note |
|
||||
| `t` | New task |
|
||||
| `c` | Focus chat input |
|
||||
| `e` | Edit current item |
|
||||
| `/` | Search |
|
||||
| `?` | Show shortcuts panel |
|
||||
| `j` / `k` | Navigate list items |
|
||||
| `Enter` | Open selected item |
|
||||
| `Escape` | Close panel / blur / go home (progressive) |
|
||||
| `Ctrl+S` | Save in editor |
|
||||
@@ -1,538 +0,0 @@
|
||||
# Backup Service Rewrite Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rewrite `services/backup.py` so that full and user backup/restore correctly includes every model added since the original implementation (projects, milestones, task logs, drafts, versions), with correct FK re-mapping on restore.
|
||||
|
||||
**Architecture:** Bump backup JSON format to version 2. Export is additive — all new tables exported. Restore builds an ID map for each table and patches FK references in the correct dependency order. V1 backups continue to restore via the existing code path.
|
||||
|
||||
**Tech Stack:** Python/SQLAlchemy 2.0 async, no new dependencies.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-11-backup-rewrite-design.md`
|
||||
|
||||
**Dependency note:** This plan can be split into Part A (pre-sharing models) and Part B (sharing models). Part A is independent and should be done first.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend export — full backup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/backup.py`
|
||||
|
||||
- [ ] **Step 1: Read the current `export_full_backup()` function**
|
||||
|
||||
Understand what is currently exported: users, notes (partial fields), conversations+messages, settings.
|
||||
|
||||
- [ ] **Step 2: Rewrite `export_full_backup()` to version 2**
|
||||
|
||||
Add all missing models to the import list at the top of `backup.py`:
|
||||
```python
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
from fabledassistant.models.task_log import TaskLog
|
||||
from fabledassistant.models.note_draft import NoteDraft
|
||||
from fabledassistant.models.note_version import NoteVersion
|
||||
from fabledassistant.models.push_subscription import PushSubscription
|
||||
```
|
||||
|
||||
Rewrite `export_full_backup()`:
|
||||
```python
|
||||
async def export_full_backup() -> dict:
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
projects = (await session.execute(select(Project))).scalars().all()
|
||||
milestones = (await session.execute(select(Milestone))).scalars().all()
|
||||
notes = (await session.execute(select(Note))).scalars().all()
|
||||
task_logs = (await session.execute(select(TaskLog))).scalars().all()
|
||||
note_drafts = (await session.execute(select(NoteDraft))).scalars().all()
|
||||
note_versions = (await session.execute(select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.version_number))).scalars().all()
|
||||
conversations = (await session.execute(
|
||||
select(Conversation).options(selectinload(Conversation.messages))
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting))).scalars().all()
|
||||
push_subs = (await session.execute(select(PushSubscription))).scalars().all()
|
||||
|
||||
return {
|
||||
"version": 2,
|
||||
"scope": "full",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"_security_notice": (
|
||||
"This backup contains hashed passwords and push subscription keys. "
|
||||
"Store it securely and restrict access."
|
||||
),
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"password_hash": u.password_hash,
|
||||
"oauth_sub": u.oauth_sub,
|
||||
"role": u.role,
|
||||
"session_version": u.session_version,
|
||||
"created_at": u.created_at.isoformat(),
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"id": p.id,
|
||||
"user_id": p.user_id,
|
||||
"title": p.title,
|
||||
"description": p.description,
|
||||
"goal": p.goal,
|
||||
"status": p.status,
|
||||
"color": p.color,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
"updated_at": p.updated_at.isoformat(),
|
||||
}
|
||||
for p in projects
|
||||
],
|
||||
"milestones": [
|
||||
{
|
||||
"id": m.id,
|
||||
"user_id": m.user_id,
|
||||
"project_id": m.project_id,
|
||||
"title": m.title,
|
||||
"description": m.description,
|
||||
"status": m.status,
|
||||
"order_index": m.order_index,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"updated_at": m.updated_at.isoformat(),
|
||||
}
|
||||
for m in milestones
|
||||
],
|
||||
"notes": [
|
||||
{
|
||||
"id": n.id,
|
||||
"user_id": n.user_id,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"tags": n.tags or [],
|
||||
"parent_id": n.parent_id,
|
||||
"project_id": n.project_id,
|
||||
"milestone_id": n.milestone_id,
|
||||
"is_task": n.is_task,
|
||||
"status": n.status,
|
||||
"priority": n.priority,
|
||||
"due_date": n.due_date.isoformat() if n.due_date else None,
|
||||
"is_starred": getattr(n, "is_starred", False),
|
||||
"is_pinned": getattr(n, "is_pinned", False),
|
||||
"created_at": n.created_at.isoformat(),
|
||||
"updated_at": n.updated_at.isoformat(),
|
||||
}
|
||||
for n in notes
|
||||
],
|
||||
"task_logs": [
|
||||
{
|
||||
"id": tl.id,
|
||||
"user_id": tl.user_id,
|
||||
"note_id": tl.note_id,
|
||||
"description": tl.description,
|
||||
"duration_minutes": tl.duration_minutes,
|
||||
"logged_at": tl.logged_at.isoformat() if tl.logged_at else None,
|
||||
"created_at": tl.created_at.isoformat(),
|
||||
}
|
||||
for tl in task_logs
|
||||
],
|
||||
"note_drafts": [
|
||||
{
|
||||
"id": nd.id,
|
||||
"user_id": nd.user_id,
|
||||
"note_id": nd.note_id,
|
||||
"title": nd.title,
|
||||
"body": nd.body,
|
||||
"saved_at": nd.saved_at.isoformat() if nd.saved_at else None,
|
||||
}
|
||||
for nd in note_drafts
|
||||
],
|
||||
"note_versions": [
|
||||
{
|
||||
"id": nv.id,
|
||||
"user_id": nv.user_id,
|
||||
"note_id": nv.note_id,
|
||||
"title": nv.title,
|
||||
"body": nv.body,
|
||||
"version_number": nv.version_number,
|
||||
"created_at": nv.created_at.isoformat(),
|
||||
}
|
||||
for nv in note_versions
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"id": c.id,
|
||||
"user_id": c.user_id,
|
||||
"title": c.title,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"messages": [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"context_note_id": m.context_note_id,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in c.messages
|
||||
],
|
||||
}
|
||||
for c in conversations
|
||||
],
|
||||
"settings": [
|
||||
{"user_id": s.user_id, "key": s.key, "value": s.value}
|
||||
for s in settings
|
||||
],
|
||||
"push_subscriptions": [
|
||||
{
|
||||
"user_id": ps.user_id,
|
||||
"endpoint": ps.endpoint,
|
||||
"p256dh": ps.p256dh,
|
||||
"auth": ps.auth,
|
||||
"created_at": ps.created_at.isoformat(),
|
||||
}
|
||||
for ps in push_subs
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Check what exact field names exist on each model before assuming. Use `getattr(obj, "field", default)` for fields that may not exist on older DB versions.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/backup.py
|
||||
git commit -m "feat(backup): v2 full export with projects, milestones, task_logs, drafts, versions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extend export — user backup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/backup.py`
|
||||
|
||||
- [ ] **Step 1: Rewrite `export_user_backup(user_id)`**
|
||||
|
||||
Same structure as full backup but filtered to `WHERE user_id = uid`. Apply same field additions. Omit sensitive fields (password_hash, oauth_sub) from user self-export.
|
||||
|
||||
```python
|
||||
async def export_user_backup(user_id: int) -> dict:
|
||||
async with async_session() as session:
|
||||
user = await session.get(User, user_id)
|
||||
projects = (await session.execute(select(Project).where(Project.user_id == user_id))).scalars().all()
|
||||
milestones = (await session.execute(select(Milestone).where(Milestone.user_id == user_id))).scalars().all()
|
||||
notes = (await session.execute(select(Note).where(Note.user_id == user_id))).scalars().all()
|
||||
task_logs = (await session.execute(select(TaskLog).where(TaskLog.user_id == user_id))).scalars().all()
|
||||
note_drafts = (await session.execute(select(NoteDraft).where(NoteDraft.user_id == user_id))).scalars().all()
|
||||
note_versions = (await session.execute(
|
||||
select(NoteVersion).where(NoteVersion.user_id == user_id)
|
||||
.order_by(NoteVersion.note_id, NoteVersion.version_number)
|
||||
)).scalars().all()
|
||||
conversations = (await session.execute(
|
||||
select(Conversation).options(selectinload(Conversation.messages))
|
||||
.where(Conversation.user_id == user_id)
|
||||
)).scalars().all()
|
||||
settings = (await session.execute(select(Setting).where(Setting.user_id == user_id))).scalars().all()
|
||||
|
||||
return {
|
||||
"version": 2,
|
||||
"scope": "user",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
} if user else None,
|
||||
"projects": [...], # same as full but user-filtered
|
||||
"milestones": [...],
|
||||
"notes": [...],
|
||||
"task_logs": [...],
|
||||
"note_drafts": [...],
|
||||
"note_versions": [...],
|
||||
"conversations": [...],
|
||||
"settings": [...],
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/backup.py
|
||||
git commit -m "feat(backup): v2 user backup with all models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Rewrite restore for v2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/fabledassistant/services/backup.py`
|
||||
|
||||
- [ ] **Step 1: Add version dispatch to `restore_full_backup`**
|
||||
|
||||
```python
|
||||
async def restore_full_backup(data: dict) -> dict:
|
||||
version = data.get("version", 1)
|
||||
if version == 1:
|
||||
return await _restore_v1(data)
|
||||
return await _restore_v2(data)
|
||||
```
|
||||
|
||||
Move existing restore code into `_restore_v1(data)` with no changes.
|
||||
|
||||
- [ ] **Step 2: Implement `_restore_v2(data)` with full FK re-mapping**
|
||||
|
||||
Restore order (respects FK dependencies):
|
||||
1. Users → build `user_id_map`
|
||||
2. Projects (fk: user_id) → build `project_id_map`
|
||||
3. Milestones (fk: user_id, project_id) → build `milestone_id_map`
|
||||
4. Notes — first pass: insert without `parent_id` (fk: user_id, project_id, milestone_id) → build `note_id_map`
|
||||
5. Notes — second pass: patch `parent_id` using `note_id_map`
|
||||
6. TaskLogs (fk: user_id, note_id via note_id_map)
|
||||
7. NoteDrafts (fk: user_id, note_id via note_id_map)
|
||||
8. NoteVersions (fk: user_id, note_id via note_id_map) — export only, no restore by default (skip unless flag set)
|
||||
9. Conversations (fk: user_id) → Messages (fk: conversation_id, context_note_id via note_id_map)
|
||||
10. Settings (fk: user_id)
|
||||
|
||||
```python
|
||||
async def _restore_v2(data: dict) -> dict:
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
stats = {
|
||||
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
|
||||
"task_logs": 0, "note_drafts": 0, "conversations": 0,
|
||||
"messages": 0, "settings": 0
|
||||
}
|
||||
|
||||
async with async_session() as session:
|
||||
user_id_map: dict[int, int] = {}
|
||||
project_id_map: dict[int, int] = {}
|
||||
milestone_id_map: dict[int, int] = {}
|
||||
note_id_map: dict[int, int] = {}
|
||||
|
||||
# 1. Users
|
||||
for u_data in data.get("users", []):
|
||||
old_id = u_data["id"]
|
||||
user = User(
|
||||
username=u_data["username"],
|
||||
email=u_data.get("email"),
|
||||
password_hash=u_data.get("password_hash"),
|
||||
oauth_sub=u_data.get("oauth_sub"),
|
||||
role=u_data.get("role", "user"),
|
||||
session_version=u_data.get("session_version", 1),
|
||||
created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
user_id_map[old_id] = user.id
|
||||
stats["users"] += 1
|
||||
|
||||
# 2. Projects
|
||||
for p_data in data.get("projects", []):
|
||||
mapped_uid = user_id_map.get(p_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
proj = Project(
|
||||
user_id=mapped_uid,
|
||||
title=p_data.get("title", ""),
|
||||
description=p_data.get("description"),
|
||||
goal=p_data.get("goal"),
|
||||
status=p_data.get("status", "active"),
|
||||
color=p_data.get("color"),
|
||||
created_at=datetime.fromisoformat(p_data["created_at"]) if p_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(p_data["updated_at"]) if p_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(proj)
|
||||
await session.flush()
|
||||
project_id_map[p_data["id"]] = proj.id
|
||||
stats["projects"] += 1
|
||||
|
||||
# 3. Milestones
|
||||
for m_data in data.get("milestones", []):
|
||||
mapped_uid = user_id_map.get(m_data.get("user_id", 0))
|
||||
mapped_pid = project_id_map.get(m_data.get("project_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
ms = Milestone(
|
||||
user_id=mapped_uid,
|
||||
project_id=mapped_pid,
|
||||
title=m_data.get("title", ""),
|
||||
description=m_data.get("description"),
|
||||
status=m_data.get("status", "open"),
|
||||
order_index=m_data.get("order_index", 0),
|
||||
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(m_data["updated_at"]) if m_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(ms)
|
||||
await session.flush()
|
||||
milestone_id_map[m_data["id"]] = ms.id
|
||||
stats["milestones"] += 1
|
||||
|
||||
# 4. Notes — first pass (no parent_id)
|
||||
note_objects: list[tuple[int, int]] = [] # (old_parent_id, new_note_id)
|
||||
for n_data in data.get("notes", []):
|
||||
mapped_uid = user_id_map.get(n_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
due = None
|
||||
if n_data.get("due_date"):
|
||||
due = date.fromisoformat(n_data["due_date"])
|
||||
note = Note(
|
||||
user_id=mapped_uid,
|
||||
title=n_data.get("title", ""),
|
||||
body=n_data.get("body", ""),
|
||||
tags=n_data.get("tags", []),
|
||||
parent_id=None, # patched in second pass
|
||||
project_id=project_id_map.get(n_data.get("project_id")) if n_data.get("project_id") else None,
|
||||
milestone_id=milestone_id_map.get(n_data.get("milestone_id")) if n_data.get("milestone_id") else None,
|
||||
is_task=n_data.get("is_task", False),
|
||||
status=n_data.get("status"),
|
||||
priority=n_data.get("priority"),
|
||||
due_date=due,
|
||||
created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(note)
|
||||
await session.flush()
|
||||
note_id_map[n_data["id"]] = note.id
|
||||
if n_data.get("parent_id"):
|
||||
note_objects.append((n_data["parent_id"], note.id))
|
||||
stats["notes"] += 1
|
||||
|
||||
# 5. Notes — second pass: patch parent_id
|
||||
for old_parent_id, new_note_id in note_objects:
|
||||
new_parent_id = note_id_map.get(old_parent_id)
|
||||
if new_parent_id:
|
||||
note_row = await session.get(Note, new_note_id)
|
||||
if note_row:
|
||||
note_row.parent_id = new_parent_id
|
||||
|
||||
# 6. TaskLogs
|
||||
for tl_data in data.get("task_logs", []):
|
||||
mapped_uid = user_id_map.get(tl_data.get("user_id", 0))
|
||||
mapped_nid = note_id_map.get(tl_data.get("note_id", 0))
|
||||
if mapped_uid is None or mapped_nid is None:
|
||||
continue
|
||||
from fabledassistant.models.task_log import TaskLog
|
||||
tl = TaskLog(
|
||||
user_id=mapped_uid,
|
||||
note_id=mapped_nid,
|
||||
description=tl_data.get("description", ""),
|
||||
duration_minutes=tl_data.get("duration_minutes"),
|
||||
logged_at=datetime.fromisoformat(tl_data["logged_at"]) if tl_data.get("logged_at") else None,
|
||||
created_at=datetime.fromisoformat(tl_data["created_at"]) if tl_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(tl)
|
||||
stats["task_logs"] += 1
|
||||
|
||||
# 7. NoteDrafts
|
||||
for nd_data in data.get("note_drafts", []):
|
||||
mapped_uid = user_id_map.get(nd_data.get("user_id", 0))
|
||||
mapped_nid = note_id_map.get(nd_data.get("note_id", 0))
|
||||
if mapped_uid is None or mapped_nid is None:
|
||||
continue
|
||||
from fabledassistant.models.note_draft import NoteDraft
|
||||
nd = NoteDraft(
|
||||
user_id=mapped_uid,
|
||||
note_id=mapped_nid,
|
||||
title=nd_data.get("title", ""),
|
||||
body=nd_data.get("body", ""),
|
||||
saved_at=datetime.fromisoformat(nd_data["saved_at"]) if nd_data.get("saved_at") else None,
|
||||
)
|
||||
session.add(nd)
|
||||
stats["note_drafts"] += 1
|
||||
|
||||
# 8. Conversations + Messages
|
||||
for c_data in data.get("conversations", []):
|
||||
mapped_uid = user_id_map.get(c_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
conv = Conversation(
|
||||
user_id=mapped_uid,
|
||||
title=c_data.get("title", ""),
|
||||
created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc),
|
||||
updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(conv)
|
||||
await session.flush()
|
||||
stats["conversations"] += 1
|
||||
for m_data in c_data.get("messages", []):
|
||||
msg = Message(
|
||||
conversation_id=conv.id,
|
||||
role=m_data["role"],
|
||||
content=m_data.get("content", ""),
|
||||
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
|
||||
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(msg)
|
||||
stats["messages"] += 1
|
||||
|
||||
# 9. Settings
|
||||
for s_data in data.get("settings", []):
|
||||
mapped_uid = user_id_map.get(s_data.get("user_id", 0))
|
||||
if mapped_uid is None:
|
||||
continue
|
||||
setting = Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", ""))
|
||||
session.add(setting)
|
||||
stats["settings"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info("Restored v2 backup: %s", stats)
|
||||
return stats
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add missing imports at top of file**
|
||||
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test restore round-trip**
|
||||
|
||||
```bash
|
||||
# Export
|
||||
curl -s -b cookies.txt "http://localhost:5000/api/admin/backup?scope=full" -o /tmp/backup_v2.json
|
||||
# Inspect structure
|
||||
python3 -c "import json; d=json.load(open('/tmp/backup_v2.json')); print(list(d.keys()))"
|
||||
# Expected keys: version, scope, exported_at, users, projects, milestones, notes, task_logs, ...
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck**
|
||||
|
||||
```bash
|
||||
docker compose exec app python -m py_compile src/fabledassistant/services/backup.py
|
||||
```
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/fabledassistant/services/backup.py
|
||||
git commit -m "feat(backup): v2 restore with full FK re-mapping; v1 restore preserved"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Verification
|
||||
|
||||
- [ ] **Step 1: Full round-trip test**
|
||||
|
||||
1. Export full backup as admin
|
||||
2. Verify JSON has `"version": 2` and all expected top-level keys
|
||||
3. Verify notes have `project_id`, `milestone_id`, `is_task` fields
|
||||
4. Verify `task_logs` array has entries (create a task log entry first if needed)
|
||||
5. Restore backup to a clean test instance (or verify restore code path runs without error in dry-run)
|
||||
|
||||
- [ ] **Step 2: V1 backward compat test**
|
||||
|
||||
Take an old v1 backup JSON (or construct one without the `version` key) and run restore. Verify it takes the v1 path and doesn't error.
|
||||
|
||||
- [ ] **Step 3: Commit final verification note**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "chore(backup): v2 backup rewrite verified"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,8 @@
|
||||
# OAuth / OIDC SSO Setup (Authentik)
|
||||
# OAuth / OIDC SSO Setup
|
||||
|
||||
Fabled Assistant supports single sign-on via any OpenID Connect provider.
|
||||
This guide covers Authentik, but the same pattern works with Keycloak, Authelia, Zitadel, etc.
|
||||
|
||||
---
|
||||
|
||||
## 1. Create the provider in Authentik
|
||||
|
||||
1. Log in to the Authentik admin UI.
|
||||
@@ -22,8 +20,6 @@ This guide covers Authentik, but the same pattern works with Keycloak, Authelia,
|
||||
https://auth.example.com/application/o/fabled-assistant/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Configure Fabled Assistant
|
||||
|
||||
Add the following environment variables to the `app` service in `docker-compose.yml`:
|
||||
@@ -44,12 +40,11 @@ services:
|
||||
# Disable local username/password login once SSO is working
|
||||
# LOCAL_AUTH_ENABLED: "false"
|
||||
|
||||
# Make sure BASE_URL matches the redirect URI you registered in Authentik
|
||||
# Make sure BASE_URL matches the redirect URI you registered
|
||||
BASE_URL: "https://your-fabled-domain"
|
||||
```
|
||||
|
||||
> **Docker Secrets alternative:** Instead of `OIDC_CLIENT_SECRET`, you can use
|
||||
> `OIDC_CLIENT_SECRET_FILE` pointing to a Docker secret file.
|
||||
> **Docker Secrets alternative:** Use `OIDC_CLIENT_SECRET_FILE` pointing to a Docker secret file instead of `OIDC_CLIENT_SECRET`.
|
||||
|
||||
Rebuild and restart:
|
||||
|
||||
@@ -57,53 +52,44 @@ Rebuild and restart:
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Verify
|
||||
|
||||
1. Open `/api/auth/status` — it should return:
|
||||
```json
|
||||
{ "oauth_enabled": true, "local_auth_enabled": true, ... }
|
||||
```
|
||||
2. Go to the login page — you should see a **"Login with Authentik"** button.
|
||||
3. Click it → you are redirected to Authentik → authenticate → redirected back to Fabled → logged in.
|
||||
2. Go to the login page — you should see a **"Login with [Provider]"** button.
|
||||
3. Click it → redirected to provider → authenticate → redirected back → logged in.
|
||||
4. Check `/api/auth/me` to confirm your user record.
|
||||
|
||||
---
|
||||
|
||||
## 4. Account linking
|
||||
## 4. Account Linking
|
||||
|
||||
When a user logs in via OAuth for the first time, Fabled checks in this order:
|
||||
|
||||
1. **Existing OAuth sub** — returns that user immediately.
|
||||
2. **Matching email** — if a local account already exists with the same email address, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
|
||||
3. **New user** — a fresh account is created. The username defaults to the `preferred_username` claim from the provider; if taken, `_2`, `_3`, etc. is appended.
|
||||
2. **Matching email** — if a local account already exists with the same email, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
|
||||
3. **New user** — a fresh account is created. Username defaults to `preferred_username` from the provider; if taken, `_2`, `_3`, etc. is appended.
|
||||
|
||||
---
|
||||
## 5. Disable Local Login (Optional)
|
||||
|
||||
## 5. Disable local login (optional)
|
||||
|
||||
Once everyone is using SSO you can hide the username/password form:
|
||||
Once everyone is using SSO:
|
||||
|
||||
```yaml
|
||||
LOCAL_AUTH_ENABLED: "false"
|
||||
```
|
||||
|
||||
The backend will reject any `POST /api/auth/login` or `POST /api/auth/register` request with a `403`. The login page will only show the SSO button.
|
||||
The backend will reject any `POST /api/auth/login` or `POST /api/auth/register` request with a 403. The login page will only show the SSO button.
|
||||
|
||||
> **Warning:** Make sure at least one account has been linked via OAuth before disabling local login, or you will be locked out.
|
||||
|
||||
---
|
||||
## 6. Other Providers
|
||||
|
||||
## 6. Other providers
|
||||
| Provider | Issuer URL format |
|
||||
|----------|------------------|
|
||||
| Authentik | `https://auth.example.com/application/o/<app-slug>/` |
|
||||
| Keycloak | `https://keycloak.example.com/realms/<realm>` |
|
||||
| Authelia | `https://auth.example.com` |
|
||||
| Zitadel | `https://your-instance.zitadel.cloud` |
|
||||
| Google | `https://accounts.google.com` |
|
||||
|
||||
| Provider | Issuer URL format |
|
||||
|------------|----------------------------------------------------------|
|
||||
| Authentik | `https://auth.example.com/application/o/<app-slug>/` |
|
||||
| Keycloak | `https://keycloak.example.com/realms/<realm>` |
|
||||
| Authelia | `https://auth.example.com` |
|
||||
| Zitadel | `https://your-instance.zitadel.cloud` |
|
||||
| Google | `https://accounts.google.com` |
|
||||
|
||||
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be
|
||||
publicly reachable from the Fabled container (server-to-server call).
|
||||
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be publicly reachable from the Fabled container (server-to-server call at login time).
|
||||
@@ -0,0 +1,2 @@
|
||||
FABLE_URL=http://localhost:5000
|
||||
FABLE_API_KEY=fmcp_your_key_here
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Async HTTP client for the Fable Assistant API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class FableAPIError(Exception):
|
||||
"""Raised when the Fable API returns a non-2xx response."""
|
||||
|
||||
def __init__(self, status_code: int, message: str) -> None:
|
||||
self.status_code = status_code
|
||||
super().__init__(f"Fable API error {status_code}: {message}")
|
||||
|
||||
|
||||
class FableClient:
|
||||
"""Async wrapper around httpx for the Fable REST API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
url = os.environ.get("FABLE_URL", "").rstrip("/")
|
||||
key = os.environ.get("FABLE_API_KEY", "")
|
||||
if not url:
|
||||
raise ValueError("FABLE_URL environment variable is required")
|
||||
if not key:
|
||||
raise ValueError("FABLE_API_KEY environment variable is required")
|
||||
self.base_url = url
|
||||
self._headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def __aenter__(self) -> "FableClient":
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=self._headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
raise RuntimeError("FableClient must be used as an async context manager")
|
||||
return self._client
|
||||
|
||||
@staticmethod
|
||||
def _raise_for_status(response: httpx.Response) -> None:
|
||||
if response.is_error:
|
||||
try:
|
||||
message = response.json().get("error", response.text)
|
||||
except Exception:
|
||||
message = response.text
|
||||
raise FableAPIError(response.status_code, message)
|
||||
|
||||
async def get(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self._http().get(path, **kwargs)
|
||||
self._raise_for_status(response)
|
||||
return response.json()
|
||||
|
||||
async def post(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self._http().post(path, **kwargs)
|
||||
self._raise_for_status(response)
|
||||
return response.json()
|
||||
|
||||
async def patch(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self._http().patch(path, **kwargs)
|
||||
self._raise_for_status(response)
|
||||
return response.json()
|
||||
|
||||
async def put(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self._http().put(path, **kwargs)
|
||||
self._raise_for_status(response)
|
||||
return response.json()
|
||||
|
||||
async def delete(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self._http().delete(path, **kwargs)
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
self._raise_for_status(response)
|
||||
if response.content:
|
||||
return response.json()
|
||||
return None
|
||||
|
||||
async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]:
|
||||
"""Yield non-empty lines from a streaming GET response (SSE)."""
|
||||
async with self._http().stream("GET", path, **kwargs) as response:
|
||||
self._raise_for_status(response)
|
||||
async for line in response.aiter_lines():
|
||||
if line:
|
||||
yield line
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_client: FableClient | None = None
|
||||
|
||||
|
||||
def init_client() -> FableClient:
|
||||
"""Initialise the module-level FableClient singleton (reads env vars)."""
|
||||
global _client
|
||||
_client = FableClient()
|
||||
return _client
|
||||
|
||||
|
||||
def get_client() -> FableClient:
|
||||
"""Return the singleton client; raises RuntimeError if not initialised."""
|
||||
if _client is None:
|
||||
raise RuntimeError("Call init_client() before get_client()")
|
||||
return _client
|
||||
|
||||
|
||||
def _reset_client() -> None:
|
||||
"""Reset the singleton — used by tests only."""
|
||||
global _client
|
||||
_client = None
|
||||
@@ -0,0 +1,643 @@
|
||||
"""Fable MCP server — exposes Fable Assistant as MCP tools via stdio transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, briefing
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_INSTRUCTIONS = """
|
||||
Fable Assistant is a self-hosted second-brain and project management system with LLM integration.
|
||||
|
||||
## Data model
|
||||
|
||||
The hierarchy is: Project → Milestone → Task/Note.
|
||||
|
||||
- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`.
|
||||
The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks.
|
||||
Do not use note tools to manipulate tasks or vice versa.
|
||||
|
||||
- **Projects** group related work. A project has a title, description, goal, status, and an
|
||||
auto-generated summary used for semantic search. Status values: `active`, `archived`.
|
||||
|
||||
- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`.
|
||||
|
||||
- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`.
|
||||
- Status values: `todo`, `in_progress`, `done`, `cancelled`
|
||||
- Priority values: `low`, `normal`, `high`
|
||||
|
||||
- **Notes** are free-form markdown documents. They can belong to a project or be standalone
|
||||
(orphan notes). Orphan notes are included in the default RAG scope for chat conversations.
|
||||
|
||||
## Tags
|
||||
|
||||
Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`.
|
||||
Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags`
|
||||
leaves existing tags unchanged on updates.
|
||||
|
||||
## Integer-or-none fields
|
||||
|
||||
Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id)
|
||||
use `0` to mean "not set / no association". Pass `0` to leave the field unset.
|
||||
|
||||
## Search
|
||||
|
||||
`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find
|
||||
relevant content by meaning rather than exact keywords. Returns results ranked by cosine
|
||||
similarity with id, title, a body snippet, and tags.
|
||||
|
||||
## Chat / LLM delegation
|
||||
|
||||
`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable
|
||||
handles its own tool use, RAG context injection, and conversation history internally.
|
||||
|
||||
Use `fable_send_message` when:
|
||||
- The request is conversational or requires Fable's internal reasoning across many records
|
||||
- You want Fable's RAG to surface relevant notes automatically
|
||||
|
||||
Use the direct CRUD tools when:
|
||||
- You know exactly what to create/read/update/delete
|
||||
- You need structured data back (IDs, field values) for further processing
|
||||
- You are populating Fable programmatically from another system
|
||||
|
||||
## Task logs
|
||||
|
||||
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
|
||||
its main body. Suitable for recording work sessions, decisions, or status updates over time.
|
||||
|
||||
## RSS / Briefing
|
||||
|
||||
Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items.
|
||||
Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing.
|
||||
|
||||
## Admin logs
|
||||
|
||||
`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected.
|
||||
"""
|
||||
|
||||
mcp = FastMCP("fable", instructions=_INSTRUCTIONS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_notes(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
tag: str = "",
|
||||
search_text: str = "",
|
||||
) -> dict:
|
||||
"""List notes (non-task documents) stored in Fable.
|
||||
|
||||
Optionally filter by a single tag (plain string, no # prefix) or a keyword search
|
||||
against title and body. Results are ordered by last-updated descending.
|
||||
|
||||
Use fable_search for semantic/meaning-based lookup instead of exact keyword search.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await notes.list_notes(
|
||||
client,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
tag=tag or None,
|
||||
search=search_text or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_note(note_id: int) -> dict:
|
||||
"""Fetch the full content of a single Fable note by its ID.
|
||||
|
||||
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await notes.get_note(client, note_id=note_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_create_note(
|
||||
title: str,
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""Create a new note in Fable.
|
||||
|
||||
Args:
|
||||
title: Note title (required).
|
||||
body: Markdown content. Supports [[wikilinks]] to other notes by title.
|
||||
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
|
||||
project_id: Associate with a project (use 0 for no project / orphan note).
|
||||
|
||||
Returns the created note object including its assigned id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await notes.create_note(
|
||||
client,
|
||||
title=title,
|
||||
body=body,
|
||||
tags=tags,
|
||||
project_id=project_id or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_update_note(
|
||||
note_id: int,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""Update an existing Fable note. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
note_id: ID of the note to update.
|
||||
title: New title, or omit to leave unchanged.
|
||||
body: New markdown body, or omit to leave unchanged.
|
||||
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
|
||||
project_id: New project association (0 = remove from project). Omit to leave unchanged.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await notes.update_note(
|
||||
client,
|
||||
note_id=note_id,
|
||||
title=title or None,
|
||||
body=body or None,
|
||||
tags=tags,
|
||||
project_id=project_id or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_delete_note(note_id: int) -> str:
|
||||
"""Permanently delete a Fable note by ID. This cannot be undone."""
|
||||
async with FableClient() as client:
|
||||
await notes.delete_note(client, note_id=note_id)
|
||||
return f"Note {note_id} deleted."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_tasks(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
status: str = "",
|
||||
project_id: int = 0,
|
||||
) -> dict:
|
||||
"""List tasks in Fable.
|
||||
|
||||
Args:
|
||||
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
|
||||
project_id: Filter to a specific project. Use 0 for no filter.
|
||||
|
||||
Results are ordered by last-updated descending.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await tasks.list_tasks(
|
||||
client,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
status=status or None,
|
||||
project_id=project_id or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_task(task_id: int) -> dict:
|
||||
"""Fetch a single Fable task by ID.
|
||||
|
||||
Returns id, title, body, status, priority, tags, project_id, milestone_id,
|
||||
parent_id, parent_title, due_date, created_at, updated_at.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await tasks.get_task(client, task_id=task_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_create_task(
|
||||
title: str,
|
||||
body: str = "",
|
||||
status: str = "todo",
|
||||
priority: str = "",
|
||||
project_id: int = 0,
|
||||
milestone_id: int = 0,
|
||||
parent_id: int = 0,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a new task in Fable.
|
||||
|
||||
Args:
|
||||
title: Task title (required).
|
||||
body: Markdown description / notes for the task.
|
||||
status: Initial status — one of: todo (default), in_progress, done, cancelled.
|
||||
priority: One of: low, normal, high. Omit for no priority.
|
||||
project_id: Associate with a project (0 = no project).
|
||||
milestone_id: Place within a project milestone (0 = no milestone).
|
||||
parent_id: Make this a sub-task of another task (0 = top-level).
|
||||
tags: List of plain-string tags without # prefix.
|
||||
|
||||
Returns the created task object including its assigned id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await tasks.create_task(
|
||||
client,
|
||||
title=title,
|
||||
body=body,
|
||||
status=status,
|
||||
priority=priority or None,
|
||||
project_id=project_id or None,
|
||||
milestone_id=milestone_id or None,
|
||||
parent_id=parent_id or None,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_update_task(
|
||||
task_id: int,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
status: str = "",
|
||||
priority: str = "",
|
||||
project_id: int = 0,
|
||||
milestone_id: int = 0,
|
||||
) -> dict:
|
||||
"""Update an existing Fable task. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
task_id: ID of the task to update.
|
||||
title: New title, or omit to leave unchanged.
|
||||
body: New markdown body, or omit to leave unchanged.
|
||||
status: New status — one of: todo, in_progress, done, cancelled.
|
||||
priority: New priority — one of: low, normal, high.
|
||||
project_id: New project (0 = remove from project). Omit to leave unchanged.
|
||||
milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await tasks.update_task(
|
||||
client,
|
||||
task_id=task_id,
|
||||
title=title or None,
|
||||
body=body or None,
|
||||
status=status or None,
|
||||
priority=priority or None,
|
||||
project_id=project_id or None,
|
||||
milestone_id=milestone_id or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_add_task_log(task_id: int, content: str) -> dict:
|
||||
"""Append a timestamped progress log entry to a Fable task.
|
||||
|
||||
Use this to record work sessions, decisions, or status updates over time without
|
||||
overwriting the task's main body. Each entry is stored separately and shown
|
||||
chronologically in the task view.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await tasks.add_task_log(client, task_id=task_id, content=content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_projects() -> dict:
|
||||
"""List all Fable projects for the current user.
|
||||
|
||||
Returns id, title, description, goal, status (active/archived), color,
|
||||
and a short auto-generated summary for each project.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await projects.list_projects(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_project(project_id: int) -> dict:
|
||||
"""Fetch a Fable project by ID, including its milestone summary.
|
||||
|
||||
Returns full project fields plus a milestone_summary list with each milestone's
|
||||
id, title, status, and task counts.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await projects.get_project(client, project_id=project_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_create_project(
|
||||
title: str,
|
||||
description: str = "",
|
||||
goal: str = "",
|
||||
status: str = "active",
|
||||
color: str = "",
|
||||
) -> dict:
|
||||
"""Create a new project in Fable.
|
||||
|
||||
Args:
|
||||
title: Project name (required).
|
||||
description: Short summary of what the project is.
|
||||
goal: The desired outcome or definition of done for the project.
|
||||
status: active (default) or archived.
|
||||
color: Optional hex colour for the project card (e.g. "#6366f1").
|
||||
|
||||
Returns the created project object including its assigned id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await projects.create_project(
|
||||
client,
|
||||
title=title,
|
||||
description=description,
|
||||
goal=goal or None,
|
||||
status=status,
|
||||
color=color or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_update_project(
|
||||
project_id: int,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
goal: str = "",
|
||||
status: str = "",
|
||||
color: str = "",
|
||||
) -> dict:
|
||||
"""Update an existing Fable project. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
project_id: ID of the project to update.
|
||||
title: New title, or omit to leave unchanged.
|
||||
description: New description, or omit to leave unchanged.
|
||||
goal: New goal/definition-of-done, or omit to leave unchanged.
|
||||
status: New status — active or archived.
|
||||
color: New hex colour, or omit to leave unchanged.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await projects.update_project(
|
||||
client,
|
||||
project_id=project_id,
|
||||
title=title or None,
|
||||
description=description or None,
|
||||
goal=goal or None,
|
||||
status=status or None,
|
||||
color=color or None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Milestones
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_milestones(project_id: int) -> dict:
|
||||
"""List milestones for a Fable project, ordered by order_index.
|
||||
|
||||
Returns id, title, description, status (active/done), order_index, and task counts.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await milestones.list_milestones(client, project_id=project_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_create_milestone(
|
||||
project_id: int,
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: str = "active",
|
||||
) -> dict:
|
||||
"""Create a milestone within a Fable project.
|
||||
|
||||
Args:
|
||||
project_id: The project this milestone belongs to (required).
|
||||
title: Milestone name (required).
|
||||
description: Optional description of what this milestone covers.
|
||||
status: active (default) or done.
|
||||
|
||||
Returns the created milestone including its assigned id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await milestones.create_milestone(
|
||||
client,
|
||||
project_id=project_id,
|
||||
title=title,
|
||||
description=description,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_update_milestone(
|
||||
project_id: int,
|
||||
milestone_id: int,
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
status: str = "",
|
||||
order_index: int = -1,
|
||||
) -> dict:
|
||||
"""Update a Fable milestone. Only explicitly provided fields are changed.
|
||||
|
||||
Args:
|
||||
project_id: Project the milestone belongs to.
|
||||
milestone_id: ID of the milestone to update.
|
||||
title: New title, or omit to leave unchanged.
|
||||
description: New description, or omit to leave unchanged.
|
||||
status: New status — active or done.
|
||||
order_index: New display position (0-based). Use -1 to leave unchanged.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await milestones.update_milestone(
|
||||
client,
|
||||
project_id=project_id,
|
||||
milestone_id=milestone_id,
|
||||
title=title or None,
|
||||
description=description or None,
|
||||
status=status or None,
|
||||
order_index=order_index if order_index >= 0 else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_search(
|
||||
q: str,
|
||||
content_type: str = "all",
|
||||
limit: int = 10,
|
||||
) -> dict:
|
||||
"""Semantic search over Fable notes and tasks using embedding similarity.
|
||||
|
||||
Finds content by meaning rather than exact keywords. Use this to discover
|
||||
relevant records when you don't know the exact title or tags.
|
||||
|
||||
Args:
|
||||
q: Natural-language query string.
|
||||
content_type: "note", "task", or "all" (default).
|
||||
limit: Maximum number of results (default 10).
|
||||
|
||||
Returns results ordered by cosine similarity, each with id, title, body snippet, and tags.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await search.search(client, q=q, content_type=content_type, limit=limit)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
|
||||
"""List chat conversations stored in Fable, ordered by last activity.
|
||||
|
||||
Returns id, title, message_count, created_at, updated_at for each conversation.
|
||||
Use the id with fable_send_message to continue a specific conversation.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await chat.list_conversations(client, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_send_message(
|
||||
message: str,
|
||||
conversation_id: str = "",
|
||||
think: bool = False,
|
||||
) -> dict:
|
||||
"""Send a natural-language message to Fable's built-in LLM and receive the full response.
|
||||
|
||||
Fable handles tool use, RAG context injection, and conversation history internally.
|
||||
The LLM can create/update notes and tasks, search, manage projects, and more — all
|
||||
driven by natural language without you needing to call individual tools.
|
||||
|
||||
Use this when:
|
||||
- The request is conversational or exploratory
|
||||
- You want Fable's RAG to automatically surface relevant notes as context
|
||||
- The task is complex enough to benefit from Fable's internal reasoning
|
||||
|
||||
Use the direct CRUD tools (fable_create_note, etc.) instead when you need
|
||||
structured data back or are performing bulk/programmatic operations.
|
||||
|
||||
Args:
|
||||
message: The user message to send.
|
||||
conversation_id: Continue an existing conversation by passing its id.
|
||||
Omit to start a new conversation.
|
||||
think: Enable extended reasoning mode for complex multi-step requests.
|
||||
|
||||
Returns conversation_id (for follow-up messages), the assistant response text,
|
||||
and a list of any tool_call events that fired during generation.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await chat.send_message(
|
||||
client,
|
||||
message=message,
|
||||
conversation_id=conversation_id or None,
|
||||
think=think,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin / observability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_get_app_logs(
|
||||
category: str = "error",
|
||||
limit: int = 20,
|
||||
search: str = "",
|
||||
) -> dict:
|
||||
"""Fetch Fable application logs. Requires an admin-scoped API key.
|
||||
|
||||
Args:
|
||||
category: Log category — "error" (default), "audit", or "usage".
|
||||
limit: Maximum number of log entries to return.
|
||||
search: Optional keyword filter matched against action, endpoint, username, details.
|
||||
|
||||
Returns a list of log entries ordered by most recent first.
|
||||
Regular user API keys will receive a 403 — only admin keys are accepted.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await admin.get_app_logs(
|
||||
client,
|
||||
category=category,
|
||||
limit=limit,
|
||||
search=search or None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Briefing / RSS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_list_rss_feeds() -> dict:
|
||||
"""List all RSS/Atom feeds configured in Fable for the current user.
|
||||
|
||||
Returns id, title, url, category, and last_fetched_at for each feed.
|
||||
These feeds are summarised in the user's daily briefing.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.list_rss_feeds(client)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_add_rss_feed(
|
||||
url: str,
|
||||
title: str = "",
|
||||
category: str = "",
|
||||
) -> dict:
|
||||
"""Add an RSS or Atom feed to Fable's daily briefing.
|
||||
|
||||
Args:
|
||||
url: The RSS/Atom feed URL (required).
|
||||
title: Optional display name. If omitted, auto-populated from feed metadata.
|
||||
category: Optional category label to group feeds (e.g. "news", "tech", "finance").
|
||||
|
||||
Returns the created feed object including its assigned id.
|
||||
"""
|
||||
async with FableClient() as client:
|
||||
return await briefing.add_rss_feed(
|
||||
client,
|
||||
url=url,
|
||||
title=title or None,
|
||||
category=category or None,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def fable_remove_rss_feed(feed_id: int) -> dict:
|
||||
"""Remove an RSS feed from Fable by its ID."""
|
||||
async with FableClient() as client:
|
||||
return await briefing.remove_rss_feed(client, feed_id=feed_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Validate env vars at startup — raises ValueError with a clear message if missing.
|
||||
FableClient()
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Admin tools: application log access (requires admin API key)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def get_app_logs(
|
||||
client: FableClient,
|
||||
category: str = "error",
|
||||
limit: int = 20,
|
||||
search: str | None = None,
|
||||
) -> dict:
|
||||
"""Fetch application logs from Fable. Requires an admin-scoped API key.
|
||||
|
||||
category: "error" | "audit" | "usage" (default: "error")
|
||||
"""
|
||||
params: dict = {"category": category, "limit": limit}
|
||||
if search:
|
||||
params["search"] = search
|
||||
return await client.get("/api/admin/logs", params=params)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""MCP tools for Fable RSS feed management."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_rss_feeds(client: FableClient) -> dict[str, Any]:
|
||||
"""List the user's RSS feeds."""
|
||||
return await client.get("/api/briefing/feeds")
|
||||
|
||||
|
||||
async def add_rss_feed(
|
||||
client: FableClient,
|
||||
*,
|
||||
url: str,
|
||||
title: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a new RSS feed. Title is optional — auto-populated from feed metadata."""
|
||||
payload: dict[str, Any] = {"url": url}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
if category:
|
||||
payload["category"] = category
|
||||
return await client.post("/api/briefing/feeds", json=payload)
|
||||
|
||||
|
||||
async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]:
|
||||
"""Remove an RSS feed by ID."""
|
||||
return await client.delete(f"/api/briefing/feeds/{feed_id}")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""MCP tools for Fable chat — create conversations and stream responses."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_conversations(
|
||||
client: FableClient,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""List MCP chat conversations."""
|
||||
params: dict[str, Any] = {"limit": limit, "offset": offset, "type": "mcp"}
|
||||
return await client.get("/api/chat/conversations", params=params)
|
||||
|
||||
|
||||
async def send_message(
|
||||
client: FableClient,
|
||||
*,
|
||||
message: str,
|
||||
conversation_id: str | None = None,
|
||||
think: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a message to Fable and return the full assistant response.
|
||||
|
||||
Creates a new MCP conversation if conversation_id is None.
|
||||
Streams the SSE response and accumulates token chunks into a single string.
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- "conversation_id": str
|
||||
- "response": str (full assistant message)
|
||||
- "tool_calls": list of any tool_call events observed
|
||||
"""
|
||||
if conversation_id is None:
|
||||
conv = await client.post(
|
||||
"/api/chat/conversations",
|
||||
json={"conversation_type": "mcp"},
|
||||
)
|
||||
conversation_id = conv["id"]
|
||||
|
||||
# Build SSE stream URL with message as query param
|
||||
params: dict[str, Any] = {"message": message}
|
||||
if think:
|
||||
params["think"] = "true"
|
||||
|
||||
tokens: list[str] = []
|
||||
tool_calls: list[Any] = []
|
||||
|
||||
stream_path = f"/api/chat/conversations/{conversation_id}/stream"
|
||||
async for raw_line in client.stream_get(stream_path, params=params):
|
||||
if not raw_line.startswith("data: "):
|
||||
continue
|
||||
payload_str = raw_line[len("data: "):]
|
||||
try:
|
||||
event = json.loads(payload_str)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
event_type = event.get("type")
|
||||
if event_type == "token":
|
||||
tokens.append(event.get("content", ""))
|
||||
elif event_type == "tool_call":
|
||||
tool_calls.append(event)
|
||||
elif event_type == "done":
|
||||
break
|
||||
|
||||
return {
|
||||
"conversation_id": conversation_id,
|
||||
"response": "".join(tokens),
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"""MCP tools for Fable milestones."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_milestones(client: FableClient, *, project_id: int) -> dict[str, Any]:
|
||||
"""List milestones for a project."""
|
||||
return await client.get(f"/api/projects/{project_id}/milestones")
|
||||
|
||||
|
||||
async def create_milestone(
|
||||
client: FableClient,
|
||||
*,
|
||||
project_id: int,
|
||||
title: str,
|
||||
description: str = "",
|
||||
status: str = "active",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a milestone within a project."""
|
||||
payload: dict[str, Any] = {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": status,
|
||||
}
|
||||
return await client.post(f"/api/projects/{project_id}/milestones", json=payload)
|
||||
|
||||
|
||||
async def update_milestone(
|
||||
client: FableClient,
|
||||
*,
|
||||
project_id: int,
|
||||
milestone_id: int,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
status: str | None = None,
|
||||
order_index: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing milestone."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
if order_index is not None:
|
||||
payload["order_index"] = order_index
|
||||
return await client.patch(
|
||||
f"/api/projects/{project_id}/milestones/{milestone_id}", json=payload
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""MCP tools for Fable notes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_notes(
|
||||
client: FableClient,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
tag: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List notes with optional filtering."""
|
||||
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||
if tag:
|
||||
params["tag"] = tag
|
||||
if search:
|
||||
params["search"] = search
|
||||
return await client.get("/api/notes", params=params)
|
||||
|
||||
|
||||
async def get_note(client: FableClient, *, note_id: int) -> dict[str, Any]:
|
||||
"""Fetch a single note by ID."""
|
||||
return await client.get(f"/api/notes/{note_id}")
|
||||
|
||||
|
||||
async def create_note(
|
||||
client: FableClient,
|
||||
*,
|
||||
title: str,
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
project_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new note."""
|
||||
payload: dict[str, Any] = {"title": title, "body": body}
|
||||
if tags is not None:
|
||||
payload["tags"] = tags
|
||||
if project_id is not None:
|
||||
payload["project_id"] = project_id
|
||||
return await client.post("/api/notes", json=payload)
|
||||
|
||||
|
||||
async def update_note(
|
||||
client: FableClient,
|
||||
*,
|
||||
note_id: int,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
project_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing note."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
if tags is not None:
|
||||
payload["tags"] = tags
|
||||
if project_id is not None:
|
||||
payload["project_id"] = project_id
|
||||
return await client.patch(f"/api/notes/{note_id}", json=payload)
|
||||
|
||||
|
||||
async def delete_note(client: FableClient, *, note_id: int) -> None:
|
||||
"""Delete a note by ID."""
|
||||
await client.delete(f"/api/notes/{note_id}")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""MCP tools for Fable projects."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_projects(client: FableClient) -> dict[str, Any]:
|
||||
"""List all projects for the authenticated user."""
|
||||
return await client.get("/api/projects")
|
||||
|
||||
|
||||
async def get_project(client: FableClient, *, project_id: int) -> dict[str, Any]:
|
||||
"""Fetch a project by ID (includes milestone summary)."""
|
||||
return await client.get(f"/api/projects/{project_id}")
|
||||
|
||||
|
||||
async def create_project(
|
||||
client: FableClient,
|
||||
*,
|
||||
title: str,
|
||||
description: str = "",
|
||||
goal: str | None = None,
|
||||
status: str = "active",
|
||||
color: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new project."""
|
||||
payload: dict[str, Any] = {"title": title, "description": description, "status": status}
|
||||
if goal is not None:
|
||||
payload["goal"] = goal
|
||||
if color is not None:
|
||||
payload["color"] = color
|
||||
return await client.post("/api/projects", json=payload)
|
||||
|
||||
|
||||
async def update_project(
|
||||
client: FableClient,
|
||||
*,
|
||||
project_id: int,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
goal: str | None = None,
|
||||
status: str | None = None,
|
||||
color: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing project."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if goal is not None:
|
||||
payload["goal"] = goal
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
if color is not None:
|
||||
payload["color"] = color
|
||||
return await client.patch(f"/api/projects/{project_id}", json=payload)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""MCP tool for semantic search across Fable notes and tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def search(
|
||||
client: FableClient,
|
||||
*,
|
||||
q: str,
|
||||
content_type: str = "all",
|
||||
limit: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""Semantic search over notes and/or tasks.
|
||||
|
||||
Args:
|
||||
q: Search query string.
|
||||
content_type: One of "note", "task", or "all" (default).
|
||||
limit: Maximum number of results to return.
|
||||
|
||||
Returns:
|
||||
Dict with "results" list and "total" count.
|
||||
Each result has: id, title, body, is_task, tags, similarity.
|
||||
"""
|
||||
params: dict[str, Any] = {"q": q, "limit": limit}
|
||||
if content_type != "all":
|
||||
params["content_type"] = content_type
|
||||
return await client.get("/api/search", params=params)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""MCP tools for Fable tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fable_mcp.client import FableClient
|
||||
|
||||
|
||||
async def list_tasks(
|
||||
client: FableClient,
|
||||
*,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
status: str | None = None,
|
||||
project_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List tasks with optional filtering."""
|
||||
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if project_id is not None:
|
||||
params["project_id"] = project_id
|
||||
return await client.get("/api/tasks", params=params)
|
||||
|
||||
|
||||
async def get_task(client: FableClient, *, task_id: int) -> dict[str, Any]:
|
||||
"""Fetch a single task by ID (includes parent_title)."""
|
||||
return await client.get(f"/api/tasks/{task_id}")
|
||||
|
||||
|
||||
async def create_task(
|
||||
client: FableClient,
|
||||
*,
|
||||
title: str,
|
||||
body: str = "",
|
||||
status: str = "todo",
|
||||
priority: str | None = None,
|
||||
project_id: int | None = None,
|
||||
milestone_id: int | None = None,
|
||||
parent_id: int | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new task."""
|
||||
payload: dict[str, Any] = {"title": title, "body": body, "status": status}
|
||||
if priority is not None:
|
||||
payload["priority"] = priority
|
||||
if project_id is not None:
|
||||
payload["project_id"] = project_id
|
||||
if milestone_id is not None:
|
||||
payload["milestone_id"] = milestone_id
|
||||
if parent_id is not None:
|
||||
payload["parent_id"] = parent_id
|
||||
if tags is not None:
|
||||
payload["tags"] = tags
|
||||
return await client.post("/api/tasks", json=payload)
|
||||
|
||||
|
||||
async def update_task(
|
||||
client: FableClient,
|
||||
*,
|
||||
task_id: int,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
status: str | None = None,
|
||||
priority: str | None = None,
|
||||
project_id: int | None = None,
|
||||
milestone_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing task."""
|
||||
payload: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
if priority is not None:
|
||||
payload["priority"] = priority
|
||||
if project_id is not None:
|
||||
payload["project_id"] = project_id
|
||||
if milestone_id is not None:
|
||||
payload["milestone_id"] = milestone_id
|
||||
return await client.patch(f"/api/tasks/{task_id}", json=payload)
|
||||
|
||||
|
||||
async def add_task_log(
|
||||
client: FableClient,
|
||||
*,
|
||||
task_id: int,
|
||||
content: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a log entry to a task."""
|
||||
return await client.post(f"/api/tasks/{task_id}/logs", json={"content": content})
|
||||
@@ -0,0 +1,24 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "fable-mcp"
|
||||
version = "0.1.0"
|
||||
description = "MCP server for Fabled Assistant"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.0",
|
||||
"httpx>=0.27",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
fable-mcp = "fable_mcp.server:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Shared pytest fixtures for fable-mcp tests."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""A mock FableClient that returns empty responses by default."""
|
||||
client = AsyncMock()
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
return client
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for FableClient."""
|
||||
import os
|
||||
import pytest
|
||||
import respx
|
||||
import httpx
|
||||
from unittest.mock import patch
|
||||
|
||||
from fable_mcp.client import FableClient, FableAPIError, get_client, init_client, _reset_client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singleton():
|
||||
"""Reset the module-level client singleton between tests."""
|
||||
_reset_client()
|
||||
yield
|
||||
_reset_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_env(monkeypatch):
|
||||
monkeypatch.setenv("FABLE_URL", "http://fable.test")
|
||||
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
|
||||
|
||||
|
||||
class TestFableClientInit:
|
||||
def test_missing_fable_url_raises(self, monkeypatch):
|
||||
monkeypatch.delenv("FABLE_URL", raising=False)
|
||||
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
|
||||
with pytest.raises(ValueError, match="FABLE_URL"):
|
||||
FableClient()
|
||||
|
||||
def test_missing_api_key_raises(self, monkeypatch):
|
||||
monkeypatch.setenv("FABLE_URL", "http://fable.test")
|
||||
monkeypatch.delenv("FABLE_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="FABLE_API_KEY"):
|
||||
FableClient()
|
||||
|
||||
def test_trailing_slash_stripped(self, base_env):
|
||||
with patch.dict(os.environ, {"FABLE_URL": "http://fable.test/"}):
|
||||
client = FableClient()
|
||||
assert client.base_url == "http://fable.test"
|
||||
|
||||
def test_auth_header_set(self, base_env):
|
||||
client = FableClient()
|
||||
assert client._headers["Authorization"] == "Bearer fmcp_testkey"
|
||||
|
||||
|
||||
class TestFableClientHTTP:
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_json(self, base_env):
|
||||
respx.get("http://fable.test/api/notes").mock(
|
||||
return_value=httpx.Response(200, json={"notes": []})
|
||||
)
|
||||
async with FableClient() as client:
|
||||
data = await client.get("/api/notes")
|
||||
assert data == {"notes": []}
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_raises_fable_api_error(self, base_env):
|
||||
respx.get("http://fable.test/api/notes").mock(
|
||||
return_value=httpx.Response(401, json={"error": "Unauthorized"})
|
||||
)
|
||||
async with FableClient() as client:
|
||||
with pytest.raises(FableAPIError) as exc_info:
|
||||
await client.get("/api/notes")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_sends_json(self, base_env):
|
||||
respx.post("http://fable.test/api/notes").mock(
|
||||
return_value=httpx.Response(201, json={"id": 1, "title": "Test"})
|
||||
)
|
||||
async with FableClient() as client:
|
||||
data = await client.post("/api/notes", json={"title": "Test"})
|
||||
assert data["id"] == 1
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_sends_json(self, base_env):
|
||||
respx.patch("http://fable.test/api/notes/1").mock(
|
||||
return_value=httpx.Response(200, json={"id": 1, "title": "Updated"})
|
||||
)
|
||||
async with FableClient() as client:
|
||||
data = await client.patch("/api/notes/1", json={"title": "Updated"})
|
||||
assert data["title"] == "Updated"
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_returns_none_on_204(self, base_env):
|
||||
respx.delete("http://fable.test/api/notes/1").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
async with FableClient() as client:
|
||||
result = await client.delete("/api/notes/1")
|
||||
assert result is None
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_fable_api_error_includes_message(self, base_env):
|
||||
respx.get("http://fable.test/api/notes").mock(
|
||||
return_value=httpx.Response(404, json={"error": "Not found"})
|
||||
)
|
||||
async with FableClient() as client:
|
||||
with pytest.raises(FableAPIError) as exc_info:
|
||||
await client.get("/api/notes")
|
||||
assert "Not found" in str(exc_info.value)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
def test_get_client_before_init_raises(self):
|
||||
with pytest.raises(RuntimeError, match="init_client"):
|
||||
get_client()
|
||||
|
||||
def test_init_client_returns_instance(self, base_env):
|
||||
client = init_client()
|
||||
assert isinstance(client, FableClient)
|
||||
|
||||
def test_get_client_after_init_returns_same(self, base_env):
|
||||
init_client()
|
||||
c1 = get_client()
|
||||
c2 = get_client()
|
||||
assert c1 is c2
|
||||
|
||||
|
||||
class TestStreamGet:
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_get_yields_lines(self, base_env):
|
||||
sse_body = b"data: line1\n\ndata: line2\n\n"
|
||||
respx.get("http://fable.test/api/stream").mock(
|
||||
return_value=httpx.Response(200, content=sse_body)
|
||||
)
|
||||
lines = []
|
||||
async with FableClient() as client:
|
||||
async for line in client.stream_get("/api/stream"):
|
||||
lines.append(line)
|
||||
assert "data: line1" in lines
|
||||
assert "data: line2" in lines
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for chat MCP tools."""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_client):
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_creates_conversation_and_streams(client):
|
||||
from fable_mcp.tools.chat import send_message
|
||||
|
||||
client.post = AsyncMock(return_value={"id": "conv-1"})
|
||||
|
||||
async def fake_stream(path, **kwargs):
|
||||
for line in [
|
||||
'data: {"type": "token", "content": "Hello"}',
|
||||
'data: {"type": "token", "content": " world"}',
|
||||
'data: {"type": "done"}',
|
||||
]:
|
||||
yield line
|
||||
|
||||
client.stream_get = fake_stream
|
||||
|
||||
result = await send_message(client, message="Hi", conversation_id=None)
|
||||
assert result["conversation_id"] == "conv-1"
|
||||
assert "Hello world" in result["response"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_reuses_existing_conversation(client):
|
||||
from fable_mcp.tools.chat import send_message
|
||||
|
||||
async def fake_stream(path, **kwargs):
|
||||
for line in [
|
||||
'data: {"type": "token", "content": "Reply"}',
|
||||
'data: {"type": "done"}',
|
||||
]:
|
||||
yield line
|
||||
|
||||
client.stream_get = fake_stream
|
||||
|
||||
result = await send_message(client, message="Hi", conversation_id="existing-conv")
|
||||
# Should not call post to create a conversation
|
||||
client.post.assert_not_called()
|
||||
assert result["conversation_id"] == "existing-conv"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_conversations_calls_get(client):
|
||||
from fable_mcp.tools.chat import list_conversations
|
||||
client.get = AsyncMock(return_value={"conversations": []})
|
||||
await list_conversations(client)
|
||||
client.get.assert_called_once()
|
||||
assert "/api/chat/conversations" in client.get.call_args[0][0]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests for milestones MCP tools."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_client):
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_milestones_calls_correct_endpoint(client):
|
||||
from fable_mcp.tools.milestones import list_milestones
|
||||
client.get = AsyncMock(return_value={"milestones": []})
|
||||
await list_milestones(client, project_id=3)
|
||||
client.get.assert_called_once_with("/api/projects/3/milestones")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_milestone_posts_to_project_endpoint(client):
|
||||
from fable_mcp.tools.milestones import create_milestone
|
||||
client.post = AsyncMock(return_value={"id": 10, "title": "M1"})
|
||||
await create_milestone(client, project_id=3, title="M1")
|
||||
path = client.post.call_args[0][0]
|
||||
assert path == "/api/projects/3/milestones"
|
||||
assert client.post.call_args[1]["json"]["title"] == "M1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_milestone_patches(client):
|
||||
from fable_mcp.tools.milestones import update_milestone
|
||||
client.patch = AsyncMock(return_value={"id": 10, "status": "completed"})
|
||||
await update_milestone(client, project_id=3, milestone_id=10, status="completed")
|
||||
path = client.patch.call_args[0][0]
|
||||
assert "/api/projects/3/milestones/10" in path
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for notes MCP tools."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_client):
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_notes_calls_get(client):
|
||||
from fable_mcp.tools.notes import list_notes
|
||||
client.get = AsyncMock(return_value={"notes": [], "total": 0})
|
||||
result = await list_notes(client, limit=10)
|
||||
client.get.assert_called_once()
|
||||
call_args = client.get.call_args
|
||||
assert "/api/notes" in call_args[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_note_calls_correct_endpoint(client):
|
||||
from fable_mcp.tools.notes import get_note
|
||||
client.get = AsyncMock(return_value={"id": 42, "title": "Hello"})
|
||||
result = await get_note(client, note_id=42)
|
||||
client.get.assert_called_once_with("/api/notes/42")
|
||||
assert result["id"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note_posts_payload(client):
|
||||
from fable_mcp.tools.notes import create_note
|
||||
client.post = AsyncMock(return_value={"id": 1, "title": "My Note"})
|
||||
result = await create_note(client, title="My Note", body="Content")
|
||||
client.post.assert_called_once()
|
||||
call_kwargs = client.post.call_args[1]
|
||||
assert call_kwargs["json"]["title"] == "My Note"
|
||||
assert call_kwargs["json"]["body"] == "Content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_note_patches_payload(client):
|
||||
from fable_mcp.tools.notes import update_note
|
||||
client.patch = AsyncMock(return_value={"id": 1, "title": "Updated"})
|
||||
result = await update_note(client, note_id=1, title="Updated")
|
||||
client.patch.assert_called_once()
|
||||
path = client.patch.call_args[0][0]
|
||||
assert "/api/notes/1" in path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_note_calls_delete(client):
|
||||
from fable_mcp.tools.notes import delete_note
|
||||
client.delete = AsyncMock(return_value=None)
|
||||
await delete_note(client, note_id=5)
|
||||
client.delete.assert_called_once_with("/api/notes/5")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests for projects MCP tools."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_client):
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects_calls_get(client):
|
||||
from fable_mcp.tools.projects import list_projects
|
||||
client.get = AsyncMock(return_value={"projects": []})
|
||||
await list_projects(client)
|
||||
client.get.assert_called_once_with("/api/projects")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_calls_correct_endpoint(client):
|
||||
from fable_mcp.tools.projects import get_project
|
||||
client.get = AsyncMock(return_value={"id": 2, "title": "Proj"})
|
||||
result = await get_project(client, project_id=2)
|
||||
client.get.assert_called_once_with("/api/projects/2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_posts_payload(client):
|
||||
from fable_mcp.tools.projects import create_project
|
||||
client.post = AsyncMock(return_value={"id": 4, "title": "New"})
|
||||
await create_project(client, title="New", description="Desc")
|
||||
call_kwargs = client.post.call_args[1]
|
||||
assert call_kwargs["json"]["title"] == "New"
|
||||
assert call_kwargs["json"]["description"] == "Desc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_patches(client):
|
||||
from fable_mcp.tools.projects import update_project
|
||||
client.patch = AsyncMock(return_value={"id": 4, "status": "active"})
|
||||
await update_project(client, project_id=4, status="active")
|
||||
assert "/api/projects/4" in client.patch.call_args[0][0]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for tasks MCP tools."""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_client):
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_calls_get(client):
|
||||
from fable_mcp.tools.tasks import list_tasks
|
||||
client.get = AsyncMock(return_value={"tasks": [], "total": 0})
|
||||
await list_tasks(client)
|
||||
client.get.assert_called_once()
|
||||
assert "/api/tasks" in client.get.call_args[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_task_calls_correct_endpoint(client):
|
||||
from fable_mcp.tools.tasks import get_task
|
||||
client.get = AsyncMock(return_value={"id": 7, "title": "Fix bug"})
|
||||
result = await get_task(client, task_id=7)
|
||||
client.get.assert_called_once_with("/api/tasks/7")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_posts_payload(client):
|
||||
from fable_mcp.tools.tasks import create_task
|
||||
client.post = AsyncMock(return_value={"id": 3, "title": "New task"})
|
||||
await create_task(client, title="New task")
|
||||
call_kwargs = client.post.call_args[1]
|
||||
assert call_kwargs["json"]["title"] == "New task"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task_patches(client):
|
||||
from fable_mcp.tools.tasks import update_task
|
||||
client.patch = AsyncMock(return_value={"id": 3, "status": "done"})
|
||||
await update_task(client, task_id=3, status="done")
|
||||
client.patch.assert_called_once()
|
||||
assert "/api/tasks/3" in client.patch.call_args[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_task_log_posts_to_logs_endpoint(client):
|
||||
from fable_mcp.tools.tasks import add_task_log
|
||||
client.post = AsyncMock(return_value={"id": 1, "content": "progress"})
|
||||
await add_task_log(client, task_id=9, content="progress")
|
||||
client.post.assert_called_once()
|
||||
path = client.post.call_args[0][0]
|
||||
assert path == "/api/tasks/9/logs"
|
||||
assert client.post.call_args[1]["json"]["content"] == "progress"
|
||||
Generated
+64
@@ -8,6 +8,11 @@
|
||||
"name": "fabledassistant-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
"@tiptap/core": "^3.0.0",
|
||||
"@tiptap/extension-link": "^3.0.0",
|
||||
"@tiptap/extension-list": "^3.0.0",
|
||||
@@ -564,6 +569,55 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@fullcalendar/core": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
|
||||
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"preact": "~10.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/daygrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
|
||||
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/interaction": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
|
||||
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/timegrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
|
||||
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fullcalendar/daygrid": "~6.1.20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/vue3": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz",
|
||||
"integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20",
|
||||
"vue": "^3.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2994,6 +3048,16 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.12.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
"@tiptap/core": "^3.0.0",
|
||||
"@tiptap/extension-link": "^3.0.0",
|
||||
"@tiptap/extension-list": "^3.0.0",
|
||||
|
||||
+10
-1
@@ -8,7 +8,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { apiGet, apiPut } from "@/api/client";
|
||||
|
||||
useTheme();
|
||||
|
||||
@@ -22,6 +22,8 @@ const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||
function startAppServices() {
|
||||
chatStore.startStatusPolling();
|
||||
settingsStore.fetchSettings();
|
||||
// Sync browser timezone to the server on every login/page load.
|
||||
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
|
||||
}
|
||||
|
||||
function stopAppServices() {
|
||||
@@ -82,6 +84,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
case "p": router.push("/projects"); break;
|
||||
case "c": router.push("/chat"); break;
|
||||
case "g": router.push("/graph"); break;
|
||||
case "l": router.push("/calendar"); break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -202,6 +205,12 @@ onUnmounted(() => {
|
||||
<kbd class="shortcut-key">c</kbd>
|
||||
<span class="shortcut-desc">Chat</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">l</kbd>
|
||||
<span class="shortcut-desc">Calendar</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">Esc</kbd>
|
||||
<span class="shortcut-desc">Unfocus field → go home</span>
|
||||
|
||||
+130
-5
@@ -123,7 +123,6 @@ export interface NotificationEntry {
|
||||
export interface UserSearchResult {
|
||||
id: number
|
||||
username: string
|
||||
email: string | null
|
||||
}
|
||||
|
||||
// --- User search ---
|
||||
@@ -329,7 +328,6 @@ export interface BriefingConfig {
|
||||
slots: BriefingSlots;
|
||||
notifications: boolean;
|
||||
temp_unit: 'C' | 'F';
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface BriefingFeed {
|
||||
@@ -353,6 +351,7 @@ export interface BriefingMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
created_at: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
||||
@@ -363,7 +362,6 @@ const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
timezone: '',
|
||||
};
|
||||
|
||||
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
||||
@@ -384,11 +382,17 @@ export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createBriefingFeed(url: string): Promise<BriefingFeed> {
|
||||
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', { url });
|
||||
export async function createBriefingFeed(url: string, category?: string): Promise<BriefingFeed> {
|
||||
const body: Record<string, string> = { url };
|
||||
if (category?.trim()) body.category = category.trim();
|
||||
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body);
|
||||
return { ...data, last_fetched_at: null };
|
||||
}
|
||||
|
||||
export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> {
|
||||
return apiPost('/api/briefing/feeds/refresh', {});
|
||||
}
|
||||
|
||||
export async function deleteBriefingFeed(id: number): Promise<void> {
|
||||
await apiDelete(`/api/briefing/feeds/${id}`);
|
||||
}
|
||||
@@ -411,6 +415,17 @@ export async function triggerBriefingSlot(slot: string): Promise<void> {
|
||||
await apiPost('/api/briefing/trigger', { slot });
|
||||
}
|
||||
|
||||
export async function postRssReaction(
|
||||
rssItemId: number,
|
||||
reaction: 'up' | 'down'
|
||||
): Promise<{ ok: boolean; action: string }> {
|
||||
return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction });
|
||||
}
|
||||
|
||||
export async function deleteRssReaction(rssItemId: number): Promise<void> {
|
||||
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
|
||||
}
|
||||
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
|
||||
try {
|
||||
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
|
||||
@@ -420,6 +435,10 @@ export async function geocodeAddress(address: string): Promise<{ lat: number; lo
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFableMcpInfo(): Promise<{ available: boolean; filename: string | null }> {
|
||||
return apiGet('/api/fable-mcp/info');
|
||||
}
|
||||
|
||||
export async function apiStreamPost(
|
||||
path: string,
|
||||
body: unknown,
|
||||
@@ -494,3 +513,109 @@ export async function apiStreamPost(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Calendar events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EventEntry {
|
||||
id: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
start_dt: string;
|
||||
end_dt: string | null;
|
||||
all_day: boolean;
|
||||
description: string;
|
||||
location: string;
|
||||
color: string;
|
||||
recurrence: string | null;
|
||||
caldav_uid: string;
|
||||
project_id: number | null;
|
||||
user_id: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventCreatePayload {
|
||||
title: string;
|
||||
start_dt: string;
|
||||
end_dt?: string;
|
||||
all_day?: boolean;
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
export interface EventUpdatePayload {
|
||||
title?: string;
|
||||
start_dt?: string;
|
||||
end_dt?: string;
|
||||
all_day?: boolean;
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
export async function listEvents(from: string, to: string): Promise<EventEntry[]> {
|
||||
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
||||
}
|
||||
|
||||
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
|
||||
return apiPost<EventEntry>('/api/events', payload);
|
||||
}
|
||||
|
||||
export async function getEvent(id: number): Promise<EventEntry> {
|
||||
return apiGet<EventEntry>(`/api/events/${id}`);
|
||||
}
|
||||
|
||||
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
|
||||
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteEvent(id: number): Promise<void> {
|
||||
return apiDelete(`/api/events/${id}`);
|
||||
}
|
||||
|
||||
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiKeyEntry {
|
||||
id: number
|
||||
name: string
|
||||
scope: string
|
||||
key_prefix: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export const listApiKeys = () =>
|
||||
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
|
||||
|
||||
export const createApiKey = (name: string, scope: 'read' | 'write') =>
|
||||
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
|
||||
|
||||
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
|
||||
|
||||
// ─── News ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
export interface GetNewsItemsParams {
|
||||
days?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
feed_id?: number | null
|
||||
}
|
||||
|
||||
export function getNewsItems(params: GetNewsItemsParams = {}) {
|
||||
const p = new URLSearchParams()
|
||||
if (params.days != null) p.set('days', String(params.days))
|
||||
if (params.limit != null) p.set('limit', String(params.limit))
|
||||
if (params.offset != null) p.set('offset', String(params.offset))
|
||||
if (params.feed_id != null) p.set('feed_id', String(params.feed_id))
|
||||
return apiGet<{ items: NewsItem[]; offset: number; limit: number }>(
|
||||
`/api/briefing/news?${p}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,9 @@ router.afterEach(() => {
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +128,9 @@ router.afterEach(() => {
|
||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
||||
<router-link to="/graph" class="nav-link">Graph</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
||||
<router-link to="/news" class="nav-link">News</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
|
||||
@@ -20,7 +20,6 @@ const config = reactive<BriefingConfig>({
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
timezone: '',
|
||||
})
|
||||
|
||||
// Step 2 — locations
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const props = defineProps<{
|
||||
// null = create mode; EventEntry = edit mode
|
||||
event: EventEntry | null;
|
||||
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
|
||||
initialDate?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "created", event: EventEntry): void;
|
||||
(e: "updated", event: EventEntry): void;
|
||||
(e: "deleted", id: number): void;
|
||||
}>();
|
||||
|
||||
const toast = useToastStore();
|
||||
|
||||
const isEditMode = computed(() => !!props.event);
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteConfirm = ref(false);
|
||||
|
||||
// Form fields
|
||||
const title = ref("");
|
||||
const startDate = ref("");
|
||||
const startTime = ref("");
|
||||
const endDate = ref("");
|
||||
const endTime = ref("");
|
||||
const allDay = ref(false);
|
||||
const description = ref("");
|
||||
const location = ref("");
|
||||
const color = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
|
||||
function dateFromIso(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso.slice(0, 10);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function timeFromIso(iso: string): string {
|
||||
if (!iso.includes("T")) return "09:00";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso.slice(11, 16);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toIso(date: string, time: string): string {
|
||||
if (!time) return `${date}T00:00:00`;
|
||||
// Include local timezone offset so the server stores the correct UTC time
|
||||
const local = new Date(`${date}T${time}:00`);
|
||||
const off = -local.getTimezoneOffset();
|
||||
const sign = off >= 0 ? "+" : "-";
|
||||
const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0");
|
||||
const min = String(Math.abs(off) % 60).padStart(2, "0");
|
||||
return `${date}T${time}:00${sign}${h}:${min}`;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
if (props.event) {
|
||||
title.value = props.event.title;
|
||||
allDay.value = props.event.all_day;
|
||||
startDate.value = dateFromIso(props.event.start_dt);
|
||||
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
|
||||
endDate.value = props.event.end_dt ? dateFromIso(props.event.end_dt) : "";
|
||||
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : "";
|
||||
description.value = props.event.description || "";
|
||||
location.value = props.event.location || "";
|
||||
color.value = props.event.color || "";
|
||||
projectId.value = props.event.project_id;
|
||||
} else {
|
||||
title.value = "";
|
||||
allDay.value = false;
|
||||
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
||||
startDate.value = base;
|
||||
startTime.value = "09:00";
|
||||
endDate.value = base;
|
||||
endTime.value = "10:00";
|
||||
description.value = "";
|
||||
location.value = "";
|
||||
color.value = "";
|
||||
projectId.value = null;
|
||||
}
|
||||
deleteConfirm.value = false;
|
||||
}
|
||||
|
||||
watch(() => props.event, resetForm, { immediate: true });
|
||||
watch(() => props.initialDate, resetForm);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") emit("close");
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
||||
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
||||
|
||||
async function save() {
|
||||
if (!title.value.trim()) {
|
||||
toast.show("Title is required", "error");
|
||||
return;
|
||||
}
|
||||
if (!startDate.value) {
|
||||
toast.show("Start date is required", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
||||
const end_dt = endDate.value
|
||||
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
||||
: undefined;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditMode.value && props.event) {
|
||||
const payload: EventUpdatePayload = {
|
||||
title: title.value.trim(),
|
||||
start_dt,
|
||||
end_dt,
|
||||
all_day: allDay.value,
|
||||
description: description.value,
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
};
|
||||
const updated = await updateEvent(props.event.id, payload);
|
||||
toast.show("Event updated", "success");
|
||||
emit("updated", updated);
|
||||
} else {
|
||||
const payload: EventCreatePayload = {
|
||||
title: title.value.trim(),
|
||||
start_dt,
|
||||
end_dt,
|
||||
all_day: allDay.value,
|
||||
description: description.value,
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
};
|
||||
const created = await createEvent(payload);
|
||||
toast.show("Event created", "success");
|
||||
emit("created", created);
|
||||
}
|
||||
} catch {
|
||||
toast.show("Failed to save event", "error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!props.event) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await deleteEvent(props.event.id);
|
||||
toast.show("Event deleted", "success");
|
||||
emit("deleted", props.event.id);
|
||||
} catch {
|
||||
toast.show("Failed to delete event", "error");
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="slide-over-backdrop" @click.self="emit('close')">
|
||||
<div class="slide-over-panel" role="dialog" aria-modal="true">
|
||||
<div class="so-header">
|
||||
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<button class="so-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<form class="so-form" @submit.prevent="save">
|
||||
<!-- Title -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="so-input" placeholder="Event title" autofocus />
|
||||
</div>
|
||||
|
||||
<!-- All-day toggle -->
|
||||
<div class="so-field so-field-row">
|
||||
<label class="so-label so-label-inline">All day</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="['toggle-btn', { active: allDay }]"
|
||||
@click="allDay = !allDay"
|
||||
>{{ allDay ? "Yes" : "No" }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Start</label>
|
||||
<div class="dt-row">
|
||||
<input v-model="startDate" type="date" class="so-input dt-date" />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">End <span class="so-hint">(optional)</span></label>
|
||||
<div class="dt-row">
|
||||
<input v-model="endDate" type="date" class="so-input dt-date" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Location <span class="so-hint">(optional)</span></label>
|
||||
<input v-model="location" class="so-input" placeholder="Location" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Description <span class="so-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="so-input so-textarea" placeholder="Description" rows="3" />
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="so-field so-field-row">
|
||||
<label class="so-label so-label-inline">Color</label>
|
||||
<div class="color-row">
|
||||
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
||||
<input v-model="color" class="so-input color-hex" placeholder="#6366f1" />
|
||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Project <span class="so-hint">(optional)</span></label>
|
||||
<ProjectSelector v-model="projectId" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="so-actions">
|
||||
<button type="submit" class="btn-primary" :disabled="saving">
|
||||
{{ saving ? "Saving…" : (isEditMode ? "Save" : "Create") }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
|
||||
<template v-if="isEditMode">
|
||||
<button
|
||||
v-if="!deleteConfirm"
|
||||
type="button"
|
||||
class="btn-danger-ghost"
|
||||
@click="deleteConfirm = true"
|
||||
>Delete</button>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-label">Delete this event?</span>
|
||||
<button type="button" class="btn-danger" :disabled="deleting" @click="doDelete">
|
||||
{{ deleting ? "Deleting…" : "Yes, delete" }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="deleteConfirm = false">No</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-over-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.slide-over-panel {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
border-left: 1px solid var(--color-border, #2a2b30);
|
||||
width: min(440px, 100vw);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.so-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #2a2b30);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.so-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.so-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.so-close:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.so-form {
|
||||
padding: 1.25rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.so-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.so-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
|
||||
.so-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.so-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.so-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
|
||||
.required { color: #f87171; }
|
||||
|
||||
.so-input {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text, #e8e9f0);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.so-input:focus { outline: none; border-color: var(--color-primary, #6366f1); }
|
||||
|
||||
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
|
||||
.dt-row { display: flex; gap: 0.5rem; }
|
||||
.dt-date { flex: 1; }
|
||||
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
||||
|
||||
.toggle-btn {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
|
||||
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
|
||||
.color-hex { flex: 1; }
|
||||
.btn-clear-color {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.so-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2b30);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.btn-danger-ghost {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid #ef4444;
|
||||
color: #ef4444;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger-ghost:hover { background: rgba(239, 68, 68, 0.1); }
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.delete-confirm-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
align-self: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Record<string, unknown> | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: Record<string, unknown> | null): void;
|
||||
}>();
|
||||
|
||||
type RecurrenceType = "none" | "interval" | "calendar";
|
||||
type IntervalUnit = "day" | "week" | "month" | "year";
|
||||
type CalendarUnit = "month" | "year";
|
||||
|
||||
const rType = ref<RecurrenceType>("none");
|
||||
const intervalEvery = ref(1);
|
||||
const intervalUnit = ref<IntervalUnit>("week");
|
||||
const calendarUnit = ref<CalendarUnit>("month");
|
||||
const calendarDay = ref(1);
|
||||
const calendarMonth = ref(1);
|
||||
|
||||
function ruleFromState(): Record<string, unknown> | null {
|
||||
if (rType.value === "none") return null;
|
||||
if (rType.value === "interval") {
|
||||
return { type: "interval", every: intervalEvery.value, unit: intervalUnit.value };
|
||||
}
|
||||
// calendar
|
||||
const rule: Record<string, unknown> = {
|
||||
type: "calendar",
|
||||
unit: calendarUnit.value,
|
||||
day_of_month: calendarDay.value,
|
||||
};
|
||||
if (calendarUnit.value === "year") {
|
||||
rule.month = calendarMonth.value;
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
function loadFromRule(rule: Record<string, unknown> | null) {
|
||||
if (!rule) {
|
||||
rType.value = "none";
|
||||
return;
|
||||
}
|
||||
const t = rule.type as string;
|
||||
if (t === "interval") {
|
||||
rType.value = "interval";
|
||||
intervalEvery.value = (rule.every as number) ?? 1;
|
||||
intervalUnit.value = (rule.unit as IntervalUnit) ?? "week";
|
||||
} else if (t === "calendar") {
|
||||
rType.value = "calendar";
|
||||
calendarUnit.value = (rule.unit as CalendarUnit) ?? "month";
|
||||
calendarDay.value = (rule.day_of_month as number) ?? 1;
|
||||
calendarMonth.value = (rule.month as number) ?? 1;
|
||||
} else {
|
||||
rType.value = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial value
|
||||
loadFromRule(props.modelValue);
|
||||
|
||||
// Watch for external changes (e.g., task loaded)
|
||||
watch(() => props.modelValue, (val) => {
|
||||
loadFromRule(val);
|
||||
}, { deep: true });
|
||||
|
||||
// Emit on any state change
|
||||
function onChange() {
|
||||
emit("update:modelValue", ruleFromState());
|
||||
}
|
||||
|
||||
const monthNames = [
|
||||
"January","February","March","April","May","June",
|
||||
"July","August","September","October","November","December",
|
||||
];
|
||||
|
||||
const calendarDayMax = computed(() =>
|
||||
calendarUnit.value === "month" ? 28 : 28
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rec-editor">
|
||||
<select v-model="rType" class="sb-select" @change="onChange">
|
||||
<option value="none">No recurrence</option>
|
||||
<option value="interval">Every N days/weeks/months</option>
|
||||
<option value="calendar">On a calendar date</option>
|
||||
</select>
|
||||
|
||||
<div v-if="rType === 'interval'" class="rec-row">
|
||||
<span class="rec-label">Every</span>
|
||||
<input
|
||||
v-model.number="intervalEvery"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
class="rec-num-input"
|
||||
@input="onChange"
|
||||
/>
|
||||
<select v-model="intervalUnit" class="sb-select rec-unit" @change="onChange">
|
||||
<option value="day">day(s)</option>
|
||||
<option value="week">week(s)</option>
|
||||
<option value="month">month(s)</option>
|
||||
<option value="year">year(s)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="rType === 'calendar'" class="rec-row rec-col">
|
||||
<div class="rec-row">
|
||||
<span class="rec-label">Unit</span>
|
||||
<select v-model="calendarUnit" class="sb-select" @change="onChange">
|
||||
<option value="month">Monthly</option>
|
||||
<option value="year">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rec-row">
|
||||
<span class="rec-label">Day</span>
|
||||
<input
|
||||
v-model.number="calendarDay"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="calendarDayMax"
|
||||
class="rec-num-input"
|
||||
@input="onChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="calendarUnit === 'year'" class="rec-row">
|
||||
<span class="rec-label">Month</span>
|
||||
<select v-model.number="calendarMonth" class="sb-select" @change="onChange">
|
||||
<option v-for="(name, i) in monthNames" :key="i + 1" :value="i + 1">{{ name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rec-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rec-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rec-col {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.rec-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
.rec-num-input {
|
||||
width: 4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid var(--color-input-border, var(--color-border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.rec-unit { min-width: 6rem; }
|
||||
</style>
|
||||
@@ -138,7 +138,6 @@ onMounted(async () => {
|
||||
<ul v-if="userResults.length" class="user-results">
|
||||
<li v-for="u in userResults" :key="u.id" @click="selectUser(u)" class="user-result-item">
|
||||
<span class="user-result-name">{{ u.username }}</span>
|
||||
<span class="user-result-email">{{ u.email }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ const labels: Record<TaskStatus, string> = {
|
||||
todo: "Todo",
|
||||
in_progress: "In Progress",
|
||||
done: "Done",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -48,6 +49,10 @@ const labels: Record<TaskStatus, string> = {
|
||||
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
|
||||
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
|
||||
}
|
||||
.status-cancelled {
|
||||
background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -20,18 +20,21 @@ const statusCycle: Record<TaskStatus, TaskStatus> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: "todo",
|
||||
cancelled: "todo",
|
||||
};
|
||||
|
||||
const statusDotClass: Record<TaskStatus, string> = {
|
||||
todo: "dot-todo",
|
||||
in_progress: "dot-in-progress",
|
||||
done: "dot-done",
|
||||
cancelled: "dot-cancelled",
|
||||
};
|
||||
|
||||
const statusTitle: Record<TaskStatus, string> = {
|
||||
todo: "Todo — click to mark In Progress",
|
||||
in_progress: "In Progress — click to mark Done",
|
||||
done: "Done — click to mark Todo",
|
||||
cancelled: "Cancelled — click to mark Todo",
|
||||
};
|
||||
|
||||
function cycleStatus() {
|
||||
@@ -152,6 +155,9 @@ function isOverdue(): boolean {
|
||||
.dot-done {
|
||||
background: var(--color-status-done, #22c55e);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--color-status-cancelled, #6b7280);
|
||||
}
|
||||
|
||||
.task-title-compact {
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { apiPost } from "@/api/client";
|
||||
import { apiPost, getEvent } from "@/api/client";
|
||||
import type { EventEntry } from "@/api/client";
|
||||
import type { ToolCallRecord } from "@/types/chat";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
toolCall: ToolCallRecord;
|
||||
@@ -67,19 +69,19 @@ const suggestedTags = computed(() => props.toolCall.result.suggested_tags ?? [])
|
||||
const eventData = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "event") return null;
|
||||
return data as { title: string; start: string; end: string };
|
||||
return data as { id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string };
|
||||
});
|
||||
|
||||
const updatedEvent = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "event_updated") return null;
|
||||
return data as { title: string; start: string; end: string };
|
||||
return data as { id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string };
|
||||
});
|
||||
|
||||
const deletedEvent = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "event_deleted") return null;
|
||||
return data as { title: string };
|
||||
return data as { id?: number; title: string };
|
||||
});
|
||||
|
||||
const calendarList = computed(() => {
|
||||
@@ -111,7 +113,7 @@ const todoCount = computed(() => {
|
||||
const eventList = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "events") return null;
|
||||
return (data.events as Array<{ title: string; start: string; end: string; location?: string }> | undefined) ?? [];
|
||||
return (data.events as Array<{ id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string }> | undefined) ?? [];
|
||||
});
|
||||
|
||||
const eventCount = computed(() => {
|
||||
@@ -251,6 +253,26 @@ async function applyTag(tag: string) {
|
||||
applyingTag.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event slide-over ─────────────────────────────────────────────────────────
|
||||
|
||||
const eventSlideOverOpen = ref(false);
|
||||
const eventSlideOverEntry = ref<EventEntry | null>(null);
|
||||
|
||||
async function openEventSlideOver(id: number | undefined) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const entry = await getEvent(id);
|
||||
eventSlideOverEntry.value = entry;
|
||||
eventSlideOverOpen.value = true;
|
||||
} catch {
|
||||
// silently fail — event may have been deleted
|
||||
}
|
||||
}
|
||||
|
||||
function closeEventSlideOver() {
|
||||
eventSlideOverOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -316,12 +338,26 @@ async function applyTag(tag: string) {
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="eventData">
|
||||
<span class="tool-event-title">{{ eventData.title }}</span>
|
||||
<span class="tool-event-time">{{ formatEventTime(eventData.start) }}</span>
|
||||
<button v-if="eventData.id" class="tool-event-btn" @click.stop="openEventSlideOver(eventData.id)">
|
||||
<span class="tool-event-dot" v-if="eventData.color" :style="{ background: eventData.color }"></span>
|
||||
<span class="tool-event-title">{{ eventData.title }}</span>
|
||||
<span v-if="eventData.start_dt" class="tool-event-time">{{ formatEventTime(eventData.start_dt) }}</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<span class="tool-event-title">{{ eventData.title }}</span>
|
||||
<span v-if="eventData.start_dt" class="tool-event-time">{{ formatEventTime(eventData.start_dt) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="updatedEvent">
|
||||
<span class="tool-event-title">{{ updatedEvent.title }}</span>
|
||||
<span class="tool-event-time">{{ formatEventTime(updatedEvent.start) }}</span>
|
||||
<button v-if="updatedEvent.id" class="tool-event-btn" @click.stop="openEventSlideOver(updatedEvent.id)">
|
||||
<span class="tool-event-dot" v-if="updatedEvent.color" :style="{ background: updatedEvent.color }"></span>
|
||||
<span class="tool-event-title">{{ updatedEvent.title }}</span>
|
||||
<span v-if="updatedEvent.start_dt" class="tool-event-time">{{ formatEventTime(updatedEvent.start_dt) }}</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<span class="tool-event-title">{{ updatedEvent.title }}</span>
|
||||
<span v-if="updatedEvent.start_dt" class="tool-event-time">{{ formatEventTime(updatedEvent.start_dt) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="deletedEvent">
|
||||
<span class="tool-deleted">{{ deletedEvent.title }}</span>
|
||||
@@ -410,11 +446,21 @@ async function applyTag(tag: string) {
|
||||
</template>
|
||||
|
||||
<template v-else-if="eventList !== null && eventList.length > 0">
|
||||
<div class="tool-event-list">
|
||||
<div v-for="(ev, i) in eventList.slice(0, 5)" :key="i" class="tool-event-item">
|
||||
<span class="tool-event-item-title">{{ ev.title }}</span>
|
||||
<span class="tool-event-item-time">{{ formatEventTime(ev.start) }}</span>
|
||||
</div>
|
||||
<div class="tool-event-cards">
|
||||
<button
|
||||
v-for="(ev, i) in eventList.slice(0, 5)"
|
||||
:key="i"
|
||||
class="tool-event-card"
|
||||
:class="{ clickable: !!ev.id }"
|
||||
@click="openEventSlideOver(ev.id)"
|
||||
>
|
||||
<span class="tool-event-card-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
||||
<span class="tool-event-card-body">
|
||||
<span class="tool-event-card-title">{{ ev.title }}</span>
|
||||
<span v-if="ev.start_dt" class="tool-event-card-time">{{ formatEventTime(ev.start_dt) }}</span>
|
||||
<span v-if="ev.location" class="tool-event-card-loc">{{ ev.location }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="eventList.length > 5" class="tool-event-more">+{{ eventList.length - 5 }} more</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -469,6 +515,17 @@ async function applyTag(tag: string) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event slide-over (portal-like, fixed positioned) -->
|
||||
<EventSlideOver
|
||||
v-if="eventSlideOverOpen"
|
||||
:event="eventSlideOverEntry"
|
||||
initial-date=""
|
||||
@close="closeEventSlideOver"
|
||||
@created="closeEventSlideOver"
|
||||
@updated="closeEventSlideOver"
|
||||
@deleted="closeEventSlideOver"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -671,4 +728,82 @@ async function applyTag(tag: string) {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
|
||||
/* ── Event header click button ── */
|
||||
.tool-event-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tool-event-btn:hover .tool-event-title {
|
||||
text-decoration: underline;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tool-event-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Event cards (list) ── */
|
||||
.tool-event-cards { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.tool-event-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card, #16161a);
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
cursor: default;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.tool-event-card.clickable { cursor: pointer; }
|
||||
.tool-event-card.clickable:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card, #16161a));
|
||||
}
|
||||
.tool-event-card-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.tool-event-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.tool-event-card-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tool-event-card-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.tool-event-card-loc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface ForecastDay {
|
||||
day: string
|
||||
condition: string
|
||||
high: number
|
||||
low: number
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
location: string
|
||||
fetched_at: string
|
||||
current_temp: number
|
||||
condition: string
|
||||
today_high: number | null
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
forecast: ForecastDay[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
weather: WeatherData | null
|
||||
tempUnit?: string
|
||||
}>()
|
||||
|
||||
const unit = computed(() => props.tempUnit ?? 'C')
|
||||
|
||||
const tempDelta = computed(() => {
|
||||
const w = props.weather
|
||||
if (!w || w.today_high == null || w.yesterday_high == null) return null
|
||||
const diff = w.today_high - w.yesterday_high
|
||||
if (Math.abs(diff) < 1) return 'Same as yesterday'
|
||||
const dir = diff > 0 ? 'warmer' : 'cooler'
|
||||
return `${Math.abs(diff)}° ${dir} than yesterday`
|
||||
})
|
||||
|
||||
const fetchedAtLabel = computed(() => {
|
||||
if (!props.weather?.fetched_at) return ''
|
||||
try {
|
||||
return new Date(props.weather.fetched_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="weather" class="weather-card">
|
||||
<div class="weather-header">
|
||||
<span class="weather-location">{{ weather.location }}</span>
|
||||
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
|
||||
</div>
|
||||
<div class="weather-current">
|
||||
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
|
||||
<span class="weather-condition">{{ weather.condition }}</span>
|
||||
</div>
|
||||
<div class="weather-today" v-if="weather.today_high != null">
|
||||
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
||||
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
||||
</div>
|
||||
<div class="weather-forecast" v-if="weather.forecast.length">
|
||||
<div v-for="day in weather.forecast" :key="day.day" class="weather-forecast-day">
|
||||
<span class="forecast-day-name">{{ day.day }}</span>
|
||||
<span class="forecast-condition">{{ day.condition }}</span>
|
||||
<span class="forecast-temps">{{ day.high }}° / {{ day.low }}°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="weather-card weather-unavailable">
|
||||
Weather data unavailable — will retry at next slot.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.weather-card {
|
||||
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.weather-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-location {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.weather-fetched-at {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.weather-current {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-temp {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weather-condition {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.weather-today {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.weather-delta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.weather-forecast {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.weather-forecast-day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 4.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.forecast-day-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forecast-condition {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.forecast-temps {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weather-unavailable {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
/**
|
||||
* Runs `refreshFn` on a recurring interval while the page is visible.
|
||||
* Safe to use in any component — timer is cleared on unmount.
|
||||
*
|
||||
* @param refreshFn Called each tick. Should be silent (no loading state changes).
|
||||
* @param intervalMs Polling interval in milliseconds.
|
||||
* @param canRun Optional guard — refresh is skipped when this returns false.
|
||||
*/
|
||||
export function useBackgroundRefresh(
|
||||
refreshFn: () => void,
|
||||
intervalMs: number,
|
||||
canRun?: () => boolean,
|
||||
): void {
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
if (canRun && !canRun()) return
|
||||
refreshFn()
|
||||
}, intervalMs)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer !== null) clearInterval(timer)
|
||||
})
|
||||
}
|
||||
@@ -110,11 +110,21 @@ const router = createRouter({
|
||||
name: "shared-with-me",
|
||||
component: () => import("@/views/SharedWithMeView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/calendar",
|
||||
name: "calendar",
|
||||
component: () => import("@/views/CalendarView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/briefing",
|
||||
name: "briefing",
|
||||
component: () => import("@/views/BriefingView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/news",
|
||||
name: "news",
|
||||
component: () => import("@/views/NewsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
|
||||
@@ -75,6 +75,17 @@ export const useChatStore = defineStore("chat", () => {
|
||||
const streamingPendingTool = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.pendingTool ?? null);
|
||||
const lastContextMeta = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.contextMeta ?? null);
|
||||
|
||||
const ragProjectId = computed<number | null>(
|
||||
() => currentConversation.value?.rag_project_id ?? null
|
||||
);
|
||||
|
||||
async function updateRagScope(convId: number, ragProjectId: number | null): Promise<void> {
|
||||
await apiPatch(`/api/chat/conversations/${convId}`, { rag_project_id: ragProjectId });
|
||||
if (currentConversation.value?.id === convId) {
|
||||
currentConversation.value.rag_project_id = ragProjectId;
|
||||
}
|
||||
}
|
||||
|
||||
function isStreamingConv(id: number): boolean {
|
||||
return convStreams.value[id]?.streaming ?? false;
|
||||
}
|
||||
@@ -313,6 +324,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
think,
|
||||
rag_project_id: ragProjectId ?? undefined,
|
||||
workspace_project_id: workspaceProjectId ?? undefined,
|
||||
user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
);
|
||||
assistantMessageId = resp.assistant_message_id;
|
||||
@@ -387,6 +399,10 @@ export const useChatStore = defineStore("chat", () => {
|
||||
};
|
||||
if (currentConversation.value?.id === convId) {
|
||||
currentConversation.value.messages.push(assistantMsg);
|
||||
// Update RAG scope if the model changed it mid-conversation
|
||||
if (event.data.new_rag_scope !== undefined) {
|
||||
currentConversation.value.rag_project_id = event.data.new_rag_scope as number | null;
|
||||
}
|
||||
}
|
||||
// Update updated_at only — message_count was already incremented at send time
|
||||
const idx = conversations.value.findIndex((c) => c.id === convId);
|
||||
@@ -594,6 +610,8 @@ export const useChatStore = defineStore("chat", () => {
|
||||
streamingStatus,
|
||||
streamingPendingTool,
|
||||
lastContextMeta,
|
||||
ragProjectId,
|
||||
updateRagScope,
|
||||
ollamaStatus,
|
||||
modelStatus,
|
||||
defaultModel,
|
||||
|
||||
@@ -12,8 +12,8 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
|
||||
// Filter / pagination / sort state
|
||||
const activeTagFilters = ref<string[]>([]);
|
||||
const statusFilter = ref<TaskStatus | "">("");
|
||||
const priorityFilter = ref<TaskPriority | "">("");
|
||||
const statusFilter = ref<TaskStatus[]>([]);
|
||||
const priorityFilter = ref<TaskPriority[]>([]);
|
||||
const limit = ref(20);
|
||||
const offset = ref(0);
|
||||
const sortField = ref("updated_at");
|
||||
@@ -28,9 +28,8 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
for (const t of activeTagFilters.value) {
|
||||
searchParams.append("tag", t);
|
||||
}
|
||||
if (statusFilter.value) searchParams.set("status", statusFilter.value);
|
||||
if (priorityFilter.value)
|
||||
searchParams.set("priority", priorityFilter.value);
|
||||
for (const s of statusFilter.value) searchParams.append("status", s);
|
||||
for (const p of priorityFilter.value) searchParams.append("priority", p);
|
||||
searchParams.set("sort", sortField.value);
|
||||
searchParams.set("order", sortOrder.value);
|
||||
searchParams.set("limit", String(limit.value));
|
||||
@@ -72,6 +71,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
project_id?: number | null;
|
||||
milestone_id?: number | null;
|
||||
parent_id?: number | null;
|
||||
recurrence_rule?: Record<string, unknown> | null;
|
||||
}): Promise<Task> {
|
||||
try {
|
||||
return await apiPost<Task>("/api/tasks", data);
|
||||
@@ -84,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
async function updateTask(
|
||||
id: number,
|
||||
data: Partial<
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id">
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
||||
>
|
||||
): Promise<Task> {
|
||||
try {
|
||||
@@ -129,14 +129,14 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function setStatusFilter(status: TaskStatus | "") {
|
||||
statusFilter.value = status;
|
||||
function setStatusFilter(statuses: TaskStatus[]) {
|
||||
statusFilter.value = statuses;
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function setPriorityFilter(priority: TaskPriority | "") {
|
||||
priorityFilter.value = priority;
|
||||
function setPriorityFilter(priorities: TaskPriority[]) {
|
||||
priorityFilter.value = priorities;
|
||||
offset.value = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface Conversation {
|
||||
title: string;
|
||||
model: string;
|
||||
message_count: number;
|
||||
rag_project_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface NewsItem {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
published_at: string | null
|
||||
topics: string[]
|
||||
source: string
|
||||
reaction: 'up' | 'down' | null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type TaskStatus = "todo" | "in_progress" | "done";
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
|
||||
export interface Note {
|
||||
@@ -13,6 +13,10 @@ export interface Note {
|
||||
status: TaskStatus | null;
|
||||
priority: TaskPriority | null;
|
||||
due_date: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
recurrence_rule: Record<string, unknown> | null;
|
||||
recurrence_next_spawn_at: string | null;
|
||||
is_task: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -38,7 +38,7 @@ const headingRenderer = {
|
||||
marked.use({ renderer: headingRenderer });
|
||||
|
||||
const PURIFY_OPTS_FULL = {
|
||||
ADD_ATTR: ["data-tag", "data-title", "src", "alt"],
|
||||
ADD_ATTR: ["data-tag", "data-title"],
|
||||
FORCE_BODY: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Cyclic color palette for milestone progress bars. */
|
||||
export const MILESTONE_PALETTE = [
|
||||
'var(--color-primary)',
|
||||
'var(--color-success, #22c55e)',
|
||||
'#c98a00',
|
||||
'#8b5cf6',
|
||||
'var(--color-danger, #ef4444)',
|
||||
'#06b6d4',
|
||||
]
|
||||
|
||||
export function milestoneColor(index: number): string {
|
||||
return MILESTONE_PALETTE[index % MILESTONE_PALETTE.length]
|
||||
}
|
||||
@@ -5,6 +5,17 @@ function escapeHtmlAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Decode HTML entities that marked introduces before we re-escape for our own output.
|
||||
// Order matters: & must be last to avoid double-decoding.
|
||||
function decodeHtmlEntities(s: string): string {
|
||||
return s
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
export function extractTags(body: string): string[] {
|
||||
const cleaned = body.replace(CODE_FENCE_RE, "");
|
||||
const tags = new Set<string>();
|
||||
@@ -39,8 +50,8 @@ export function linkifyWikilinks(html: string): string {
|
||||
.map((part, i) => {
|
||||
if (i % 2 === 1) return part;
|
||||
return part.replace(WIKILINK_RE, (_full, title: string, display?: string) => {
|
||||
const trimmed = title.trim();
|
||||
const label = display || trimmed;
|
||||
const trimmed = decodeHtmlEntities(title.trim());
|
||||
const label = display ? decodeHtmlEntities(display) : trimmed;
|
||||
const encoded = encodeURIComponent(trimmed);
|
||||
return `<a class="wikilink" data-title="${escapeHtmlAttr(trimmed)}" href="/notes/by-title?title=${encoded}">${escapeHtmlAttr(label)}</a>`;
|
||||
});
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import ChatMessage from '@/components/ChatMessage.vue'
|
||||
import WeatherCard from '@/components/WeatherCard.vue'
|
||||
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
|
||||
import {
|
||||
apiGet,
|
||||
getBriefingConfig,
|
||||
getBriefingConversations,
|
||||
getBriefingToday,
|
||||
getBriefingConvMessages,
|
||||
triggerBriefingSlot,
|
||||
postRssReaction,
|
||||
deleteRssReaction,
|
||||
getNewsItems,
|
||||
type BriefingConversation,
|
||||
type BriefingMessage,
|
||||
} from '@/api/client'
|
||||
import type { Message } from '@/types/chat'
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
interface WeatherData {
|
||||
location: string
|
||||
fetched_at: string
|
||||
current_temp: number
|
||||
condition: string
|
||||
today_high: number | null
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
forecast: { day: string; condition: string; high: number; low: number }[]
|
||||
}
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
@@ -42,15 +61,55 @@ const isToday = computed(() => selectedConvId.value === todayConvId.value)
|
||||
const messages = ref<BriefingMessage[]>([])
|
||||
const loadingMessages = ref(false)
|
||||
|
||||
// Weather panel (left column)
|
||||
const weatherData = ref<WeatherData[]>([])
|
||||
const tempUnit = ref<string>('C')
|
||||
|
||||
async function loadWeather() {
|
||||
try {
|
||||
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
|
||||
weatherData.value = data.locations ?? []
|
||||
tempUnit.value = data.temp_unit ?? 'C'
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// News panel (right column)
|
||||
const newsItems = ref<NewsItem[]>([])
|
||||
|
||||
async function loadNews() {
|
||||
try {
|
||||
const data = await getNewsItems({ days: 2, limit: 40 })
|
||||
newsItems.value = data.items
|
||||
// Seed reactions from API response
|
||||
for (const item of data.items) {
|
||||
if (reactions.value[item.id] === undefined) {
|
||||
reactions.value[item.id] = item.reaction
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// Scroll to bottom of messages
|
||||
const messagesEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesEl.value) {
|
||||
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [convList, today] = await Promise.all([
|
||||
getBriefingConversations(),
|
||||
getBriefingToday().catch(() => null),
|
||||
loadWeather(),
|
||||
loadNews(),
|
||||
])
|
||||
conversations.value = convList
|
||||
if (today) {
|
||||
todayConvId.value = today.id
|
||||
// Ensure today is in the list
|
||||
if (!convList.find((c) => c.id === today.id)) {
|
||||
conversations.value = [
|
||||
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
|
||||
@@ -59,9 +118,9 @@ async function loadAll() {
|
||||
}
|
||||
selectedConvId.value = today.id
|
||||
messages.value = today.messages
|
||||
// Load into chatStore so we can stream
|
||||
await chatStore.fetchConversation(today.id)
|
||||
}
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
watch(selectedConvId, async (id) => {
|
||||
@@ -69,11 +128,13 @@ watch(selectedConvId, async (id) => {
|
||||
if (id === todayConvId.value) {
|
||||
await chatStore.fetchConversation(id)
|
||||
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
|
||||
scrollToBottom()
|
||||
return
|
||||
}
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
messages.value = await getBriefingConvMessages(id)
|
||||
scrollToBottom()
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
@@ -84,6 +145,7 @@ watch(() => chatStore.streaming, async (streaming) => {
|
||||
if (!streaming && selectedConvId.value === todayConvId.value && todayConvId.value) {
|
||||
const today = await getBriefingToday().catch(() => null)
|
||||
if (today) messages.value = today.messages
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -94,7 +156,6 @@ const sending = ref(false)
|
||||
async function send() {
|
||||
const text = input.value.trim()
|
||||
if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
|
||||
// Ensure today's conv is loaded in chatStore
|
||||
if (chatStore.currentConversation?.id !== todayConvId.value) {
|
||||
await chatStore.fetchConversation(todayConvId.value)
|
||||
}
|
||||
@@ -114,13 +175,39 @@ function onKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
|
||||
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
||||
|
||||
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
|
||||
const current = reactions.value[itemId]
|
||||
reactions.value[itemId] = current === reaction ? null : reaction
|
||||
try {
|
||||
if (current === reaction) {
|
||||
await deleteRssReaction(itemId)
|
||||
} else {
|
||||
await postRssReaction(itemId, reaction)
|
||||
}
|
||||
} catch {
|
||||
reactions.value[itemId] = current ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffH = (now.getTime() - d.getTime()) / 3_600_000
|
||||
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
||||
if (diffH < 48) return 'Yesterday'
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
// Manual trigger
|
||||
const triggering = ref(false)
|
||||
async function triggerNow() {
|
||||
triggering.value = true
|
||||
try {
|
||||
await triggerBriefingSlot('compilation')
|
||||
// Reload
|
||||
await loadAll()
|
||||
} finally {
|
||||
triggering.value = false
|
||||
@@ -150,6 +237,27 @@ function toMsg(m: BriefingMessage): Message {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||
async function _backgroundRefreshMessages() {
|
||||
try {
|
||||
const today = await getBriefingToday()
|
||||
if (!today) return
|
||||
const fresh = today.messages
|
||||
const last = fresh[fresh.length - 1]
|
||||
const cur = messages.value[messages.value.length - 1]
|
||||
if (fresh.length !== messages.value.length || last?.content !== cur?.content) {
|
||||
messages.value = fresh
|
||||
}
|
||||
await loadNews()
|
||||
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
||||
}
|
||||
|
||||
useBackgroundRefresh(
|
||||
_backgroundRefreshMessages,
|
||||
60_000,
|
||||
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await checkSetup()
|
||||
if (!showWizard.value) await loadAll()
|
||||
@@ -161,16 +269,15 @@ onMounted(async () => {
|
||||
<!-- Setup wizard overlay -->
|
||||
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
|
||||
|
||||
<!-- Main view (shown after wizard check and only if not showing wizard) -->
|
||||
<!-- Main view -->
|
||||
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
|
||||
<!-- Header -->
|
||||
<!-- Header spans all columns -->
|
||||
<header class="briefing-header">
|
||||
<div class="briefing-header-left">
|
||||
<h1 class="briefing-title">Briefing</h1>
|
||||
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
|
||||
</div>
|
||||
<div class="briefing-header-right">
|
||||
<!-- Conversation history dropdown -->
|
||||
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
|
||||
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
|
||||
</select>
|
||||
@@ -183,58 +290,116 @@ onMounted(async () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="briefing-messages-wrap">
|
||||
<div v-if="loadingMessages" class="briefing-loading">Loading…</div>
|
||||
<template v-else>
|
||||
<div v-if="!messages.length" class="briefing-empty">
|
||||
<p>No briefing yet for today.</p>
|
||||
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
|
||||
</div>
|
||||
<div v-else class="briefing-messages">
|
||||
<ChatMessage
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:message="toMsg(msg)"
|
||||
:is-streaming="false"
|
||||
/>
|
||||
<!-- Live streaming bubble for today -->
|
||||
<ChatMessage
|
||||
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
|
||||
:message="{
|
||||
id: -1,
|
||||
conversation_id: todayConvId ?? -1,
|
||||
role: 'assistant',
|
||||
content: chatStore.streamingContent,
|
||||
context_note_id: null,
|
||||
context_note_title: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}"
|
||||
:is-streaming="true"
|
||||
/>
|
||||
</div>
|
||||
<!-- Left column: Weather -->
|
||||
<div class="briefing-left">
|
||||
<div class="panel-label">Weather</div>
|
||||
<template v-if="weatherData.length">
|
||||
<WeatherCard
|
||||
v-for="loc in weatherData"
|
||||
:key="(loc as WeatherData).location"
|
||||
:weather="loc"
|
||||
:temp-unit="tempUnit"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="panel-empty">No weather configured</div>
|
||||
</div>
|
||||
|
||||
<!-- Input bar (today only) -->
|
||||
<div v-if="isToday" class="briefing-input-bar">
|
||||
<textarea
|
||||
v-model="input"
|
||||
class="briefing-input"
|
||||
placeholder="Reply to your briefing…"
|
||||
rows="1"
|
||||
@keydown="onKeydown"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn-send"
|
||||
@click="send"
|
||||
:disabled="!input.trim() || chatStore.streaming || sending"
|
||||
aria-label="Send"
|
||||
<!-- Center column: Chat -->
|
||||
<div class="briefing-center">
|
||||
<div class="briefing-messages-wrap" ref="messagesEl">
|
||||
<div v-if="loadingMessages" class="briefing-loading">Loading…</div>
|
||||
<template v-else>
|
||||
<div v-if="!messages.length" class="briefing-empty">
|
||||
<p>No briefing yet for today.</p>
|
||||
<p class="briefing-empty-hint">Click "Refresh" to generate a briefing now, or wait for the scheduled slot.</p>
|
||||
</div>
|
||||
<div v-else class="briefing-messages">
|
||||
<template v-for="msg in messages" :key="msg.id">
|
||||
<ChatMessage
|
||||
:message="toMsg(msg)"
|
||||
:is-streaming="false"
|
||||
/>
|
||||
</template>
|
||||
<!-- Live streaming bubble for today -->
|
||||
<ChatMessage
|
||||
v-if="isToday && chatStore.streaming && chatStore.streamingContent"
|
||||
:message="{
|
||||
id: -1,
|
||||
conversation_id: todayConvId ?? -1,
|
||||
role: 'assistant',
|
||||
content: chatStore.streamingContent,
|
||||
context_note_id: null,
|
||||
context_note_title: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}"
|
||||
:is-streaming="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Input bar (today only) -->
|
||||
<div v-if="isToday" class="briefing-input-bar">
|
||||
<textarea
|
||||
v-model="input"
|
||||
class="briefing-input"
|
||||
placeholder="Reply to your briefing…"
|
||||
rows="1"
|
||||
@keydown="onKeydown"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn-send"
|
||||
@click="send"
|
||||
:disabled="!input.trim() || chatStore.streaming || sending"
|
||||
aria-label="Send"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: News -->
|
||||
<div class="briefing-right">
|
||||
<div class="panel-label-row">
|
||||
<div class="panel-label">Today's News</div>
|
||||
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
|
||||
</div>
|
||||
<div v-if="!newsItems.length" class="panel-empty">No articles in the last 2 days</div>
|
||||
<div
|
||||
v-for="item in newsItems"
|
||||
:key="item.id"
|
||||
class="news-card"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="news-card-meta">
|
||||
<span class="news-source">{{ item.source }}</span>
|
||||
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="item.url"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="news-title"
|
||||
>{{ item.title }}</a>
|
||||
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
|
||||
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
||||
<div class="news-reactions">
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'up' }"
|
||||
@click="handleReaction(item.id, 'up')"
|
||||
title="Interested"
|
||||
>👍</button>
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'down' }"
|
||||
@click="handleReaction(item.id, 'down')"
|
||||
title="Not interested"
|
||||
>👎</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,21 +414,20 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.briefing-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.briefing-header {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 0 1rem;
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
@@ -324,10 +488,38 @@ onMounted(async () => {
|
||||
}
|
||||
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ─── Left column (Weather) ──────────────────────────────────────────────── */
|
||||
|
||||
.briefing-left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.briefing-left :deep(.weather-card) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ─── Center column (Chat) ───────────────────────────────────────────────── */
|
||||
|
||||
.briefing-center {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-messages-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0;
|
||||
padding: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.briefing-loading,
|
||||
@@ -354,7 +546,7 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
padding: 0.75rem 0 1rem;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -392,4 +584,156 @@ onMounted(async () => {
|
||||
}
|
||||
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-send:hover:not(:disabled) { opacity: 0.9; }
|
||||
|
||||
/* ─── Right column (News) ────────────────────────────────────────────────── */
|
||||
|
||||
.briefing-right {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
border-left: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-count {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.news-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.news-source {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.news-date {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.35;
|
||||
text-decoration: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||
|
||||
.news-snippet {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-reactions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.reaction-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.reaction-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.reaction-btn.active {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.briefing-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
}
|
||||
.briefing-header {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.briefing-left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
max-height: 220px;
|
||||
}
|
||||
.briefing-center {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
.briefing-right {
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import FullCalendar from "@fullcalendar/vue3";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import type { CalendarOptions, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
||||
import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const toast = useToastStore();
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Slide-over state
|
||||
const slideOverEvent = ref<EventEntry | null>(null); // null = create mode
|
||||
const slideOverOpen = ref(false);
|
||||
const slideOverDate = ref<string>("");
|
||||
|
||||
function openCreate(date: string) {
|
||||
slideOverEvent.value = null;
|
||||
slideOverDate.value = date;
|
||||
slideOverOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(event: EventEntry) {
|
||||
slideOverEvent.value = event;
|
||||
slideOverDate.value = "";
|
||||
slideOverOpen.value = true;
|
||||
}
|
||||
|
||||
function closeSlideOver() {
|
||||
slideOverOpen.value = false;
|
||||
}
|
||||
|
||||
// Event entry cache keyed by id for quick lookups when clicking FC events
|
||||
const eventCache = new Map<number, EventEntry>();
|
||||
|
||||
function toFcEvent(e: EventEntry) {
|
||||
return {
|
||||
id: String(e.id),
|
||||
title: e.title,
|
||||
start: e.start_dt,
|
||||
end: e.end_dt ?? undefined,
|
||||
allDay: e.all_day,
|
||||
backgroundColor: e.color || undefined,
|
||||
borderColor: e.color || undefined,
|
||||
extendedProps: { entryId: e.id },
|
||||
};
|
||||
}
|
||||
|
||||
async function loadEvents(
|
||||
fetchInfo: { startStr: string; endStr: string },
|
||||
successCallback: (events: object[]) => void,
|
||||
failureCallback: (error: Error) => void,
|
||||
) {
|
||||
try {
|
||||
const entries = await listEvents(fetchInfo.startStr, fetchInfo.endStr);
|
||||
eventCache.clear();
|
||||
for (const e of entries) eventCache.set(e.id, e);
|
||||
successCallback(entries.map(toFcEvent));
|
||||
} catch (err) {
|
||||
failureCallback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateClick(arg: DateClickArg) {
|
||||
openCreate(arg.dateStr);
|
||||
}
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const entry = eventCache.get(id);
|
||||
if (entry) openEdit(entry);
|
||||
}
|
||||
|
||||
async function handleEventDrop(arg: EventDropArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const start_dt = arg.event.startStr;
|
||||
const end_dt = arg.event.endStr || undefined;
|
||||
try {
|
||||
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
|
||||
eventCache.set(id, updated);
|
||||
} catch {
|
||||
arg.revert();
|
||||
toast.show("Failed to move event", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEventResize(arg: EventResizeDoneArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const start_dt = arg.event.startStr;
|
||||
const end_dt = arg.event.endStr || undefined;
|
||||
try {
|
||||
const updated = await updateEvent(id, { start_dt, end_dt });
|
||||
eventCache.set(id, updated);
|
||||
} catch {
|
||||
arg.revert();
|
||||
toast.show("Failed to resize event", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function onCreated(entry: EventEntry) {
|
||||
eventCache.set(entry.id, entry);
|
||||
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
|
||||
closeSlideOver();
|
||||
}
|
||||
|
||||
function onUpdated(entry: EventEntry) {
|
||||
eventCache.set(entry.id, entry);
|
||||
// Replace the event in FullCalendar
|
||||
const api = calendarRef.value?.getApi();
|
||||
if (api) {
|
||||
const existing = api.getEventById(String(entry.id));
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
api.addEvent(toFcEvent(entry));
|
||||
}
|
||||
}
|
||||
closeSlideOver();
|
||||
}
|
||||
|
||||
function onDeleted(id: number) {
|
||||
eventCache.delete(id);
|
||||
const api = calendarRef.value?.getApi();
|
||||
if (api) {
|
||||
api.getEventById(String(id))?.remove();
|
||||
}
|
||||
closeSlideOver();
|
||||
}
|
||||
|
||||
const calendarOptions: CalendarOptions = {
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: "dayGridMonth",
|
||||
timeZone: "local",
|
||||
editable: true,
|
||||
selectable: false,
|
||||
headerToolbar: {
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
},
|
||||
events: loadEvents,
|
||||
dateClick: handleDateClick,
|
||||
eventClick: handleEventClick,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResize: handleEventResize,
|
||||
height: "auto",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-view">
|
||||
<div class="cal-header">
|
||||
<h1 class="cal-title">Calendar</h1>
|
||||
<button class="btn-new-event" @click="openCreate(new Date().toISOString().slice(0, 10))">
|
||||
+ New Event
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fc-wrapper">
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||
</div>
|
||||
|
||||
<EventSlideOver
|
||||
v-if="slideOverOpen"
|
||||
:event="slideOverEvent"
|
||||
:initial-date="slideOverDate"
|
||||
@close="closeSlideOver"
|
||||
@created="onCreated"
|
||||
@updated="onUpdated"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-view {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.5rem 3rem;
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.cal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.btn-new-event {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-new-event:hover { opacity: 0.88; }
|
||||
|
||||
.fc-wrapper {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* FullCalendar dark theme overrides */
|
||||
:deep(.fc) {
|
||||
color: var(--color-text, #e8e9f0);
|
||||
font-family: inherit;
|
||||
}
|
||||
:deep(.fc-toolbar-title) {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
:deep(.fc-button) {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
:deep(.fc-button:hover),
|
||||
:deep(.fc-button-active) {
|
||||
background: var(--color-primary, #6366f1) !important;
|
||||
border-color: var(--color-primary, #6366f1) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
:deep(.fc-button:focus) { box-shadow: none !important; }
|
||||
:deep(.fc-daygrid-day-number),
|
||||
:deep(.fc-col-header-cell-cushion) {
|
||||
color: var(--color-text-muted, #888);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
:deep(.fc-daygrid-day.fc-day-today) {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
:deep(.fc-event) {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.fc-event-main) { color: #fff; }
|
||||
:deep(.fc-event:not([style*="background"])) {
|
||||
background: var(--color-primary, #6366f1);
|
||||
}
|
||||
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
|
||||
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
|
||||
:deep(.fc-scrollgrid td),
|
||||
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
|
||||
:deep(.fc-daygrid-day) { cursor: pointer; }
|
||||
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
||||
</style>
|
||||
+132
-25
@@ -9,7 +9,6 @@ import ChatMessage from "@/components/ChatMessage.vue";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
|
||||
import type { Note } from "@/types/note";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -47,8 +46,34 @@ const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null
|
||||
// Note IDs excluded from auto-injection on next message
|
||||
const excludedNoteIds = ref<number[]>([]);
|
||||
|
||||
// Project scope for RAG — when set, semantic & keyword search is restricted to this project
|
||||
const ragProjectId = ref<number | null>(null);
|
||||
// Scope chip state
|
||||
const scopeDropdownOpen = ref(false);
|
||||
const projects = ref<{ id: number; title: string }[]>([]);
|
||||
const scopePulse = ref(false);
|
||||
|
||||
const scopeLabel = computed(() => {
|
||||
const id = store.ragProjectId;
|
||||
if (id === -1) return "All notes";
|
||||
if (id === null) return "Orphan notes";
|
||||
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`;
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const data = await apiGet<{ projects: { id: number; title: string }[] }>("/api/projects?status=active");
|
||||
projects.value = data.projects ?? [];
|
||||
} catch {
|
||||
projects.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function onScopeSelect(value: number | null) {
|
||||
scopeDropdownOpen.value = false;
|
||||
if (!convId.value) return;
|
||||
await store.updateRagScope(convId.value, value);
|
||||
scopePulse.value = true;
|
||||
setTimeout(() => { scopePulse.value = false; }, 600);
|
||||
}
|
||||
|
||||
let prevConvId: number | null = null;
|
||||
|
||||
@@ -120,7 +145,7 @@ const inputPlaceholder = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener("keydown", onGlobalKeydown);
|
||||
await store.fetchConversations();
|
||||
await Promise.all([store.fetchConversations(), loadProjects()]);
|
||||
if (convId.value) {
|
||||
if (store.currentConversation?.id !== convId.value) {
|
||||
await store.fetchConversation(convId.value);
|
||||
@@ -305,7 +330,7 @@ async function sendMessage() {
|
||||
true, // enable thinking in the full chat view
|
||||
undefined,
|
||||
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
|
||||
ragProjectId.value,
|
||||
store.ragProjectId,
|
||||
);
|
||||
sending.value = false;
|
||||
|
||||
@@ -520,13 +545,6 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- RAG project scope -->
|
||||
<div class="rag-scope-section">
|
||||
<label class="rag-scope-label">Scope notes to project</label>
|
||||
<ProjectSelector v-model="ragProjectId" />
|
||||
<p v-if="ragProjectId" class="rag-scope-hint">RAG search restricted to this project</p>
|
||||
</div>
|
||||
|
||||
<div class="conv-list">
|
||||
<template v-for="group in groupedConversations" :key="group.label">
|
||||
<div class="conv-group-label">{{ group.label }}</div>
|
||||
@@ -726,6 +744,39 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<!-- Scope chip above input -->
|
||||
<div class="scope-chip-row">
|
||||
<div class="scope-chip-wrapper">
|
||||
<button
|
||||
class="scope-chip"
|
||||
:class="{ pulse: scopePulse }"
|
||||
@click="scopeDropdownOpen = !scopeDropdownOpen"
|
||||
title="Change RAG scope"
|
||||
>
|
||||
<span class="scope-dot">⊙</span> {{ scopeLabel }}
|
||||
</button>
|
||||
<div v-if="scopeDropdownOpen" class="scope-dropdown">
|
||||
<button
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === null }"
|
||||
@click="onScopeSelect(null)"
|
||||
>Orphan notes only</button>
|
||||
<button
|
||||
v-for="p in projects"
|
||||
:key="p.id"
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === p.id }"
|
||||
@click="onScopeSelect(p.id)"
|
||||
>{{ p.title }}</button>
|
||||
<button
|
||||
class="scope-option"
|
||||
:class="{ active: store.ragProjectId === -1 }"
|
||||
@click="onScopeSelect(-1)"
|
||||
>All notes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<!-- Research button -->
|
||||
<div class="research-wrapper">
|
||||
@@ -950,26 +1001,82 @@ onUnmounted(() => {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
.rag-scope-section {
|
||||
padding: 0.5rem 0.75rem 0.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
.scope-chip-row {
|
||||
padding: 0.35rem 0.75rem 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.rag-scope-label {
|
||||
display: block;
|
||||
.scope-chip-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.3rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.rag-scope-hint {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.7rem;
|
||||
.scope-chip:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.scope-dot {
|
||||
color: var(--color-primary);
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@keyframes scope-pulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 40%, transparent); }
|
||||
100% { box-shadow: 0 0 0 6px transparent; }
|
||||
}
|
||||
|
||||
.scope-chip.pulse {
|
||||
animation: scope-pulse 0.5s ease-out;
|
||||
}
|
||||
|
||||
.scope-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
min-width: 180px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scope-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.85rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.scope-option:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.scope-option.active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conv-list {
|
||||
|
||||
+208
-13
@@ -1,15 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { apiGet, listEvents } from "@/api/client";
|
||||
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh";
|
||||
import { milestoneColor } from "@/utils/palette";
|
||||
import type { Note } from "@/types/note";
|
||||
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
|
||||
import type { ToolCallRecord, Message } from "@/types/chat";
|
||||
import type { EventEntry } from "@/api/client";
|
||||
import NoteCard from "@/components/NoteCard.vue";
|
||||
import TaskCard from "@/components/TaskCard.vue";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import ToolCallCard from "@/components/ToolCallCard.vue";
|
||||
import DashboardChatInput from "@/components/DashboardChatInput.vue";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
|
||||
@@ -62,24 +66,56 @@ const orphanTasks = ref<Task[]>([]);
|
||||
const orphanNotes = ref<Note[]>([]);
|
||||
const inboxOpen = ref(true);
|
||||
|
||||
// ─── Milestone color palette ──────────────────────────────────────────────────
|
||||
// Upcoming events (today + next 7 days)
|
||||
const upcomingEvents = ref<EventEntry[]>([]);
|
||||
const eventSlideOverOpen = ref(false);
|
||||
const editingEvent = ref<EventEntry | null>(null);
|
||||
|
||||
function milestoneColor(index: number): string {
|
||||
const palette = [
|
||||
"var(--color-primary)",
|
||||
"var(--color-success, #22c55e)",
|
||||
"#c98a00",
|
||||
"var(--color-danger, #e74c3c)",
|
||||
"#8b5cf6",
|
||||
];
|
||||
return palette[index % palette.length];
|
||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||
// Never touches `loading` so existing content stays on screen while fetching.
|
||||
|
||||
function _dateRange() {
|
||||
const today = new Date()
|
||||
const nextWeek = new Date(today)
|
||||
nextWeek.setDate(today.getDate() + 7)
|
||||
return {
|
||||
todayStr: today.toISOString().slice(0, 10) + 'T00:00:00',
|
||||
nextWeekStr: nextWeek.toISOString().slice(0, 10) + 'T23:59:59',
|
||||
}
|
||||
}
|
||||
|
||||
function _backgroundRefresh() {
|
||||
if (document.hidden || loading.value) return
|
||||
const { todayStr, nextWeekStr } = _dateRange()
|
||||
Promise.allSettled([
|
||||
listEvents(todayStr, nextWeekStr),
|
||||
apiGet<TaskListResponse>('/api/tasks?no_project=true&sort=updated_at&order=desc&limit=8'),
|
||||
apiGet<{ notes: Note[] }>('/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6'),
|
||||
]).then(([eventsRes, tasksRes, notesRes]) => {
|
||||
if (eventsRes.status === 'fulfilled') upcomingEvents.value = eventsRes.value
|
||||
if (tasksRes.status === 'fulfilled') orphanTasks.value = tasksRes.value.tasks
|
||||
if (notesRes.status === 'fulfilled') orphanNotes.value = notesRes.value.notes
|
||||
})
|
||||
if (heroProject.value) {
|
||||
apiGet<TaskListResponse>(
|
||||
`/api/tasks?project_id=${heroProject.value.id}&status=todo&sort=updated_at&order=desc&limit=1`
|
||||
)
|
||||
.then((r) => { heroNextUp.value = r.tasks[0] ?? null })
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
// Phase 1: projects list + cross-project recent items + orphaned items — all parallel
|
||||
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes] =
|
||||
// Phase 1: projects list + cross-project recent items + orphaned items + events — all parallel
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().slice(0, 10) + "T00:00:00";
|
||||
const nextWeek = new Date(today);
|
||||
nextWeek.setDate(today.getDate() + 7);
|
||||
const nextWeekStr = nextWeek.toISOString().slice(0, 10) + "T23:59:59";
|
||||
|
||||
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes, eventsRes] =
|
||||
await Promise.allSettled([
|
||||
apiGet<{ projects: DashProject[] }>("/api/projects?status=active"),
|
||||
apiGet<{ notes: RecentItem[] }>(
|
||||
@@ -91,6 +127,7 @@ onMounted(async () => {
|
||||
apiGet<{ notes: Note[] }>(
|
||||
"/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6"
|
||||
),
|
||||
listEvents(todayStr, nextWeekStr),
|
||||
]);
|
||||
|
||||
// Determine hero project: the project whose item was most recently touched
|
||||
@@ -113,14 +150,19 @@ onMounted(async () => {
|
||||
orphanTasks.value = orphanTasksRes.value.tasks;
|
||||
if (orphanNotesRes.status === "fulfilled")
|
||||
orphanNotes.value = orphanNotesRes.value.notes;
|
||||
if (eventsRes.status === "fulfilled")
|
||||
upcomingEvents.value = eventsRes.value;
|
||||
|
||||
loading.value = false;
|
||||
|
||||
// Focus chat input after data loads
|
||||
chatInputRef.value?.focus();
|
||||
loadProjects();
|
||||
|
||||
});
|
||||
|
||||
useBackgroundRefresh(_backgroundRefresh, 90_000, () => !loading.value);
|
||||
|
||||
async function loadProjects() {
|
||||
if (!heroProject.value) return;
|
||||
const hid = heroProject.value.id;
|
||||
@@ -242,6 +284,49 @@ function clearDashboardResponse() {
|
||||
dashboardFinalContent.value = "";
|
||||
dashboardFinalToolCalls.value = [];
|
||||
}
|
||||
|
||||
// ─── Upcoming events slide-over ───────────────────────────────────────────────
|
||||
|
||||
function openEvent(event: EventEntry) {
|
||||
editingEvent.value = event;
|
||||
eventSlideOverOpen.value = true;
|
||||
}
|
||||
|
||||
function onEventUpdated(event: EventEntry) {
|
||||
const idx = upcomingEvents.value.findIndex((e) => e.id === event.id);
|
||||
if (idx !== -1) upcomingEvents.value[idx] = event;
|
||||
eventSlideOverOpen.value = false;
|
||||
}
|
||||
|
||||
function onEventDeleted(id: number) {
|
||||
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
|
||||
eventSlideOverOpen.value = false;
|
||||
}
|
||||
|
||||
function formatUpcomingTime(event: EventEntry): string {
|
||||
if (event.all_day) return "All day";
|
||||
if (!event.start_dt) return "";
|
||||
try {
|
||||
const d = new Date(event.start_dt);
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
const isToday =
|
||||
d.getFullYear() === today.getFullYear() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getDate() === today.getDate();
|
||||
const isTomorrow =
|
||||
d.getFullYear() === tomorrow.getFullYear() &&
|
||||
d.getMonth() === tomorrow.getMonth() &&
|
||||
d.getDate() === tomorrow.getDate();
|
||||
const timeStr = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
||||
if (isToday) return `Today ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow ${timeStr}`;
|
||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + " " + timeStr;
|
||||
} catch {
|
||||
return event.start_dt;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -290,6 +375,33 @@ function clearDashboardResponse() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Upcoming events ────────────────────────────────────── -->
|
||||
<div v-if="!loading && upcomingEvents.length" class="upcoming-events-section">
|
||||
<div class="section-header">
|
||||
<h2>Upcoming</h2>
|
||||
<router-link to="/calendar" class="see-all">Calendar →</router-link>
|
||||
</div>
|
||||
<div class="upcoming-events-list">
|
||||
<button
|
||||
v-for="ev in upcomingEvents.slice(0, 6)"
|
||||
:key="ev.id"
|
||||
class="upcoming-event-card"
|
||||
@click="openEvent(ev)"
|
||||
>
|
||||
<span class="upcoming-event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
||||
<span class="upcoming-event-body">
|
||||
<span class="upcoming-event-title">{{ ev.title }}</span>
|
||||
<span class="upcoming-event-time">{{ formatUpcomingTime(ev) }}</span>
|
||||
<span v-if="ev.location" class="upcoming-event-loc">{{ ev.location }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="upcomingEvents.length > 6" class="upcoming-events-more">
|
||||
+{{ upcomingEvents.length - 6 }} more —
|
||||
<router-link to="/calendar" class="see-all-inline">view all</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Skeleton while loading ─────────────────────────────── -->
|
||||
<template v-if="loading">
|
||||
<div class="skeleton-hero"></div>
|
||||
@@ -466,6 +578,17 @@ function clearDashboardResponse() {
|
||||
</template>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Event slide-over -->
|
||||
<EventSlideOver
|
||||
v-if="eventSlideOverOpen"
|
||||
:event="editingEvent"
|
||||
initial-date=""
|
||||
@close="eventSlideOverOpen = false"
|
||||
@created="eventSlideOverOpen = false"
|
||||
@updated="onEventUpdated"
|
||||
@deleted="onEventDeleted"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -917,4 +1040,76 @@ function clearDashboardResponse() {
|
||||
.projects-grid { grid-template-columns: 1fr; }
|
||||
.inbox-sub { display: none; }
|
||||
}
|
||||
|
||||
/* ─── Upcoming events ────────────────────────────────────────── */
|
||||
.upcoming-events-section {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.upcoming-events-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.upcoming-event-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.upcoming-event-card:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card));
|
||||
}
|
||||
.upcoming-event-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.upcoming-event-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.upcoming-event-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.upcoming-event-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.upcoming-event-loc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.upcoming-events-more {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.25rem 0;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.see-all-inline {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.see-all-inline:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
getBriefingFeeds,
|
||||
postRssReaction,
|
||||
deleteRssReaction,
|
||||
getNewsItems,
|
||||
type BriefingFeed,
|
||||
} from '@/api/client'
|
||||
import type { NewsItem } from '@/types/news'
|
||||
|
||||
const LIMIT = 40
|
||||
|
||||
const items = ref<NewsItem[]>([])
|
||||
const offset = ref(0)
|
||||
const hasMore = ref(true)
|
||||
const loading = ref(false)
|
||||
const feeds = ref<BriefingFeed[]>([])
|
||||
const selectedFeedId = ref<number | null>(null)
|
||||
|
||||
// Reactions map: item id → current reaction
|
||||
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || !hasMore.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getNewsItems({
|
||||
days: 90,
|
||||
limit: LIMIT,
|
||||
offset: offset.value,
|
||||
feed_id: selectedFeedId.value,
|
||||
})
|
||||
for (const item of data.items) {
|
||||
if (reactions.value[item.id] === undefined) {
|
||||
reactions.value[item.id] = item.reaction
|
||||
}
|
||||
}
|
||||
items.value = [...items.value, ...data.items]
|
||||
offset.value += data.items.length
|
||||
hasMore.value = data.items.length === LIMIT
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFeedChange() {
|
||||
items.value = []
|
||||
offset.value = 0
|
||||
hasMore.value = true
|
||||
reactions.value = {}
|
||||
loadMore()
|
||||
}
|
||||
|
||||
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
|
||||
const current = reactions.value[itemId]
|
||||
reactions.value[itemId] = current === reaction ? null : reaction
|
||||
try {
|
||||
if (current === reaction) {
|
||||
await deleteRssReaction(itemId)
|
||||
} else {
|
||||
await postRssReaction(itemId, reaction)
|
||||
}
|
||||
} catch {
|
||||
reactions.value[itemId] = current ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffH = (now.getTime() - d.getTime()) / 3_600_000
|
||||
if (diffH < 1) return 'Just now'
|
||||
if (diffH < 24) return `${Math.round(diffH)}h ago`
|
||||
if (diffH < 48) return 'Yesterday'
|
||||
const days = Math.floor(diffH / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
feeds.value = await getBriefingFeeds().catch(() => [])
|
||||
await loadMore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="news-root">
|
||||
<div class="news-header">
|
||||
<div class="news-header-left">
|
||||
<h1 class="news-title">News</h1>
|
||||
<span class="news-subtitle">Last 90 days</span>
|
||||
</div>
|
||||
<div class="news-header-right">
|
||||
<select
|
||||
v-model="selectedFeedId"
|
||||
class="feed-select"
|
||||
@change="onFeedChange"
|
||||
>
|
||||
<option :value="null">All feeds</option>
|
||||
<option v-for="feed in feeds" :key="feed.id" :value="feed.id">
|
||||
{{ feed.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="news-list">
|
||||
<div v-if="!items.length && !loading" class="news-empty">
|
||||
No articles found for the selected feed.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="news-card"
|
||||
>
|
||||
<div class="news-card-meta">
|
||||
<span class="news-source">{{ item.source }}</span>
|
||||
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="item.url"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="news-card-title"
|
||||
>{{ item.title }}</a>
|
||||
<p v-else class="news-card-title news-card-title--plain">{{ item.title }}</p>
|
||||
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
|
||||
<div v-if="item.topics?.length" class="news-topics">
|
||||
<span v-for="topic in item.topics" :key="topic" class="news-topic">{{ topic }}</span>
|
||||
</div>
|
||||
<div class="news-reactions">
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'up' }"
|
||||
@click="handleReaction(item.id, 'up')"
|
||||
title="Interested"
|
||||
>👍</button>
|
||||
<button
|
||||
class="reaction-btn"
|
||||
:class="{ active: reactions[item.id] === 'down' }"
|
||||
@click="handleReaction(item.id, 'down')"
|
||||
title="Not interested"
|
||||
>👎</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="news-footer">
|
||||
<button
|
||||
v-if="hasMore"
|
||||
class="btn-load-more"
|
||||
@click="loadMore"
|
||||
:disabled="loading"
|
||||
>{{ loading ? 'Loading…' : 'Load more' }}</button>
|
||||
<div v-if="loading && !items.length" class="news-loading">Loading…</div>
|
||||
<p v-if="!hasMore && items.length" class="news-end">All articles loaded</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.news-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.news-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.news-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.news-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.news-subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.news-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.feed-select {
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.news-list {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 860px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.news-empty,
|
||||
.news-loading {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.news-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.news-source {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.news-date {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.news-card-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
line-height: 1.4;
|
||||
text-decoration: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a.news-card-title:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.news-snippet {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.news-topic {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
color: var(--color-primary);
|
||||
border-radius: 99px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.news-reactions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.reaction-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.reaction-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.reaction-btn.active {
|
||||
opacity: 1;
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.news-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.btn-load-more {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-load-more:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-load-more:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.news-end {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { milestoneColor } from "@/utils/palette";
|
||||
|
||||
interface MilestoneSummary {
|
||||
id: number;
|
||||
@@ -73,17 +74,6 @@ async function loadProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
function milestoneColor(index: number): string {
|
||||
const palette = [
|
||||
"var(--color-primary)",
|
||||
"var(--color-success)",
|
||||
"#c98a00",
|
||||
"#8b5cf6",
|
||||
"#ef4444",
|
||||
"#06b6d4",
|
||||
];
|
||||
return palette[index % palette.length];
|
||||
}
|
||||
|
||||
onMounted(loadProjects);
|
||||
|
||||
|
||||
@@ -209,6 +209,30 @@ async function loadTasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const advancingTaskId = ref<number | null>(null);
|
||||
|
||||
const taskStatusNext: Record<string, string> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
};
|
||||
|
||||
async function advanceTaskStatus(task: NoteItem, e: Event) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const next = taskStatusNext[task.status ?? ""];
|
||||
if (!next || advancingTaskId.value === task.id) return;
|
||||
advancingTaskId.value = task.id;
|
||||
try {
|
||||
await apiPatch(`/api/notes/${task.id}`, { status: next });
|
||||
const idx = tasks.value.findIndex((t) => t.id === task.id);
|
||||
if (idx !== -1) tasks.value[idx] = { ...tasks.value[idx], status: next };
|
||||
} catch {
|
||||
toast.show("Failed to update task", "error");
|
||||
} finally {
|
||||
advancingTaskId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotes() {
|
||||
notesLoading.value = true;
|
||||
try {
|
||||
@@ -475,9 +499,17 @@ async function confirmDelete() {
|
||||
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
||||
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
|
||||
<div class="task-card-footer">
|
||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
||||
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="task-advance-btn"
|
||||
title="Move to In Progress"
|
||||
:disabled="advancingTaskId === task.id"
|
||||
@click="advanceTaskStatus(task, $event)"
|
||||
>→</button>
|
||||
</div>
|
||||
</router-link>
|
||||
<p v-if="!group.tasks.filter(t => t.status === 'todo').length" class="col-empty">No tasks</p>
|
||||
@@ -498,9 +530,17 @@ async function confirmDelete() {
|
||||
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
||||
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
|
||||
<div class="task-card-footer">
|
||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
||||
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="task-advance-btn task-advance-btn--done"
|
||||
title="Mark as Done"
|
||||
:disabled="advancingTaskId === task.id"
|
||||
@click="advanceTaskStatus(task, $event)"
|
||||
>✓</button>
|
||||
</div>
|
||||
</router-link>
|
||||
<p v-if="!group.tasks.filter(t => t.status === 'in_progress').length" class="col-empty">No tasks</p>
|
||||
@@ -1088,7 +1128,26 @@ async function confirmDelete() {
|
||||
.task-card-done .task-title { text-decoration: line-through; }
|
||||
|
||||
.task-title { display: block; font-weight: 500; margin-bottom: 0.2rem; line-height: 1.3; word-break: break-word; }
|
||||
.task-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 0.35rem; min-height: 1.2rem; }
|
||||
.task-meta { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
|
||||
.task-advance-btn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.4rem; height: 1.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
.task-card:hover .task-advance-btn { opacity: 1; }
|
||||
.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
||||
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; }
|
||||
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.priority-dot {
|
||||
width: 7px;
|
||||
|
||||
@@ -3,10 +3,11 @@ import { ref, watch, onMounted } from "vue";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, geocodeAddress, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
|
||||
import { usePushStore } from "@/stores/push";
|
||||
import type { User } from "@/types/auth";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
|
||||
const store = useSettingsStore();
|
||||
const authStore = useAuthStore();
|
||||
@@ -16,6 +17,13 @@ const assistantName = ref("");
|
||||
const defaultModel = ref("");
|
||||
const installedModels = ref<string[]>([]);
|
||||
const defaultChatModel = ref("");
|
||||
|
||||
interface OllamaModel { name: string; size: number; loaded: boolean; modified_at: string; }
|
||||
const ollamaModels = ref<OllamaModel[]>([]);
|
||||
const pullModelName = ref("");
|
||||
const pullProgress = ref<{ status: string; pct: number | null } | null>(null);
|
||||
const pulling = ref(false);
|
||||
const deletingModel = ref<string | null>(null);
|
||||
const newEmail = ref("");
|
||||
const emailPassword = ref("");
|
||||
const changingEmail = ref(false);
|
||||
@@ -32,17 +40,135 @@ const appVersion = ref('dev');
|
||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "config", "users", "logs", "groups"]);
|
||||
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
|
||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||
|
||||
function _loadTabContent(tab: string) {
|
||||
if (authStore.isAdmin) {
|
||||
if (tab === "users") loadUsersPanel();
|
||||
else if (tab === "logs") loadLogsPanel();
|
||||
else if (tab === "groups") loadGroupsPanel();
|
||||
}
|
||||
if (tab === "briefing") loadBriefingTab();
|
||||
if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
||||
}
|
||||
|
||||
watch(activeTab, (v) => {
|
||||
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
|
||||
if (v === "users" && authStore.isAdmin) loadUsersPanel();
|
||||
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
|
||||
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
|
||||
if (v === "briefing") loadBriefingTab();
|
||||
_loadTabContent(v);
|
||||
});
|
||||
|
||||
// API Keys
|
||||
const apiKeys = ref<ApiKeyEntry[]>([]);
|
||||
const newKeyName = ref('');
|
||||
const newKeyScope = ref<'read' | 'write'>('write');
|
||||
const newKeyValue = ref('');
|
||||
const apiKeyCopied = ref(false);
|
||||
const creatingApiKey = ref(false);
|
||||
const revokeConfirmId = ref<number | null>(null);
|
||||
const mcpInfo = ref<{ available: boolean; filename: string | null } | null>(null);
|
||||
const mcpInfoLoading = ref(false);
|
||||
|
||||
const origin = window.location.origin;
|
||||
const mcpConfigSnippet = JSON.stringify({
|
||||
mcpServers: {
|
||||
fable: {
|
||||
command: "fable-mcp",
|
||||
env: {
|
||||
FABLE_URL: window.location.origin,
|
||||
FABLE_API_KEY: "<your-api-key>",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
|
||||
async function loadMcpInfo() {
|
||||
if (mcpInfo.value !== null) return;
|
||||
mcpInfoLoading.value = true;
|
||||
try {
|
||||
mcpInfo.value = await getFableMcpInfo();
|
||||
} catch {
|
||||
mcpInfo.value = { available: false, filename: null };
|
||||
} finally {
|
||||
mcpInfoLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApiKeys() {
|
||||
apiKeys.value = await listApiKeys();
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
if (!newKeyName.value) return;
|
||||
creatingApiKey.value = true;
|
||||
try {
|
||||
const data = await apiCreateApiKey(newKeyName.value, newKeyScope.value);
|
||||
newKeyValue.value = data.key;
|
||||
newKeyName.value = '';
|
||||
await fetchApiKeys();
|
||||
} finally {
|
||||
creatingApiKey.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeApiKey(id: number) {
|
||||
await apiRevokeApiKey(id);
|
||||
revokeConfirmId.value = null;
|
||||
await fetchApiKeys();
|
||||
}
|
||||
|
||||
async function copyApiKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newKeyValue.value);
|
||||
} catch {
|
||||
// Fallback for http (non-secure) contexts where clipboard API is unavailable
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = newKeyValue.value;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
apiKeyCopied.value = true;
|
||||
setTimeout(() => { apiKeyCopied.value = false; }, 2000);
|
||||
}
|
||||
|
||||
function _downloadFile(filename: string, content: string, mimeType = 'text/plain') {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function downloadEnvFile() {
|
||||
const baseUrl = window.location.origin;
|
||||
const content = `FABLE_URL=${baseUrl}\nFABLE_API_KEY=${newKeyValue.value}\n`;
|
||||
_downloadFile('fable-mcp.env', content);
|
||||
}
|
||||
|
||||
function downloadMcpConfig() {
|
||||
const baseUrl = window.location.origin;
|
||||
const config = {
|
||||
mcpServers: {
|
||||
fable: {
|
||||
command: 'fable-mcp',
|
||||
env: {
|
||||
FABLE_URL: baseUrl,
|
||||
FABLE_API_KEY: newKeyValue.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
_downloadFile('fable-mcp-config.json', JSON.stringify(config, null, 2), 'application/json');
|
||||
}
|
||||
|
||||
// Groups management
|
||||
const groups = ref<GroupEntry[]>([]);
|
||||
const groupsLoading = ref(false);
|
||||
@@ -127,7 +253,6 @@ const briefingConfig = ref<BriefingConfig>({
|
||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||
notifications: true,
|
||||
temp_unit: 'C',
|
||||
timezone: '',
|
||||
});
|
||||
const briefingFeeds = ref<BriefingFeed[]>([]);
|
||||
const briefingSaving = ref(false);
|
||||
@@ -135,15 +260,43 @@ const briefingSaved = ref(false);
|
||||
const briefingGeocoding = ref<Record<string, boolean>>({});
|
||||
const briefingGeoError = ref<Record<string, string>>({});
|
||||
const newFeedUrl = ref('');
|
||||
const newFeedCategory = ref('');
|
||||
const addingFeed = ref(false);
|
||||
const refreshingFeeds = ref(false);
|
||||
const briefingIncludeTopics = ref<string[]>([]);
|
||||
const briefingExcludeTopics = ref<string[]>([]);
|
||||
|
||||
function _parseTopics(raw: unknown): string[] {
|
||||
try {
|
||||
const val = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(val) ? val.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBriefingTab() {
|
||||
briefingConfig.value = await getBriefingConfig();
|
||||
briefingFeeds.value = await getBriefingFeeds();
|
||||
// Auto-populate timezone from browser if not already stored.
|
||||
if (!briefingConfig.value.timezone) {
|
||||
briefingConfig.value.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
const allSettings = await apiGet<Record<string, string>>('/api/settings').catch(() => ({} as Record<string, string>));
|
||||
briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]');
|
||||
briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]');
|
||||
}
|
||||
|
||||
async function saveIncludeTopics(topics: string[]) {
|
||||
briefingIncludeTopics.value = topics;
|
||||
await apiPut('/api/settings', { briefing_include_topics: JSON.stringify(topics) });
|
||||
}
|
||||
|
||||
async function saveExcludeTopics(topics: string[]) {
|
||||
briefingExcludeTopics.value = topics;
|
||||
await apiPut('/api/settings', { briefing_exclude_topics: JSON.stringify(topics) });
|
||||
}
|
||||
|
||||
const _STANDARD_TOPICS = ['technology', 'science', 'politics', 'business', 'health', 'environment', 'local', 'entertainment', 'sports', 'other'];
|
||||
async function fetchTopicSuggestions(q: string): Promise<string[]> {
|
||||
if (!q) return _STANDARD_TOPICS;
|
||||
return _STANDARD_TOPICS.filter((t) => t.startsWith(q.toLowerCase()));
|
||||
}
|
||||
|
||||
async function geocodeLocation(key: 'home' | 'work') {
|
||||
@@ -198,11 +351,12 @@ async function addFeed() {
|
||||
if (!newFeedUrl.value.trim() || addingFeed.value) return;
|
||||
addingFeed.value = true;
|
||||
try {
|
||||
const feed = await createBriefingFeed(newFeedUrl.value.trim());
|
||||
const feed = await createBriefingFeed(newFeedUrl.value.trim(), newFeedCategory.value.trim() || undefined);
|
||||
briefingFeeds.value.push(feed);
|
||||
newFeedUrl.value = '';
|
||||
} catch {
|
||||
toastStore.show('Failed to add feed', 'error');
|
||||
newFeedCategory.value = '';
|
||||
} catch (err: any) {
|
||||
toastStore.show(err?.message === 'Feed already added' ? 'That feed is already in your list' : 'Failed to add feed', 'error');
|
||||
} finally {
|
||||
addingFeed.value = false;
|
||||
}
|
||||
@@ -213,6 +367,32 @@ async function removeFeed(id: number) {
|
||||
briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id);
|
||||
}
|
||||
|
||||
async function refreshFeeds() {
|
||||
if (refreshingFeeds.value) return;
|
||||
refreshingFeeds.value = true;
|
||||
try {
|
||||
const result = await refreshBriefingFeeds();
|
||||
// Reload feed list so last_fetched_at updates
|
||||
briefingFeeds.value = await getBriefingFeeds();
|
||||
toastStore.show(`Refreshed ${result.feeds_refreshed} feed${result.feeds_refreshed !== 1 ? 's' : ''} — ${result.new_items} new item${result.new_items !== 1 ? 's' : ''}`);
|
||||
} catch {
|
||||
toastStore.show('Failed to refresh feeds', 'error');
|
||||
} finally {
|
||||
refreshingFeeds.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function feedAge(isoStr: string | null): string {
|
||||
if (!isoStr) return 'never fetched';
|
||||
const diff = Date.now() - new Date(isoStr).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'just now';
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
// Chat retention
|
||||
const chatRetentionDays = ref(90);
|
||||
const savingRetention = ref(false);
|
||||
@@ -290,6 +470,7 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// Ollama unreachable — dropdowns will be empty
|
||||
}
|
||||
await loadOllamaModels();
|
||||
|
||||
// Load notification preferences from user settings
|
||||
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
||||
@@ -335,8 +516,8 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// base URL not configured yet
|
||||
}
|
||||
if (activeTab.value === "groups") loadGroupsPanel();
|
||||
}
|
||||
_loadTabContent(activeTab.value);
|
||||
});
|
||||
|
||||
async function changeEmail() {
|
||||
@@ -401,6 +582,89 @@ async function changePassword() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "—";
|
||||
const gb = bytes / 1e9;
|
||||
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
||||
return `${(bytes / 1e6).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
async function loadOllamaModels() {
|
||||
try {
|
||||
const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
|
||||
ollamaModels.value = data.models ?? [];
|
||||
installedModels.value = ollamaModels.value.map((m) => m.name);
|
||||
} catch {
|
||||
// Ollama unreachable
|
||||
}
|
||||
}
|
||||
|
||||
async function pullModel() {
|
||||
const name = pullModelName.value.trim();
|
||||
if (!name || pulling.value) return;
|
||||
pulling.value = true;
|
||||
pullProgress.value = { status: "Connecting…", pct: null };
|
||||
try {
|
||||
const resp = await fetch("/api/chat/models/pull", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: name }),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.replace(/^data: /, "").trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const msg = JSON.parse(trimmed);
|
||||
if (msg.error) throw new Error(msg.error);
|
||||
if (msg.status === "success") {
|
||||
pullProgress.value = { status: "Complete", pct: 100 };
|
||||
pullModelName.value = "";
|
||||
} else if (msg.total && msg.completed) {
|
||||
const pct = Math.round((msg.completed / msg.total) * 100);
|
||||
pullProgress.value = { status: msg.status || "Downloading…", pct };
|
||||
} else {
|
||||
pullProgress.value = { status: msg.status || "Working…", pct: null };
|
||||
}
|
||||
} catch (parseErr: any) {
|
||||
if (parseErr.message !== "JSON parse error") throw parseErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadOllamaModels();
|
||||
toastStore.show(`Model ${name} ready`);
|
||||
} catch (err: any) {
|
||||
toastStore.show(`Pull failed: ${err.message}`, "error");
|
||||
} finally {
|
||||
pulling.value = false;
|
||||
setTimeout(() => { pullProgress.value = null; }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteModel(name: string) {
|
||||
deletingModel.value = name;
|
||||
try {
|
||||
await apiPost("/api/chat/models/delete", { model: name });
|
||||
await loadOllamaModels();
|
||||
if (defaultModel.value === name) defaultModel.value = "";
|
||||
toastStore.show(`Deleted ${name}`);
|
||||
} catch {
|
||||
toastStore.show(`Failed to delete ${name}`, "error");
|
||||
} finally {
|
||||
deletingModel.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAssistant() {
|
||||
saving.value = true;
|
||||
saved.value = false;
|
||||
@@ -870,12 +1134,12 @@ function formatUserDate(iso: string): string {
|
||||
<div class="sidebar-group">
|
||||
<div class="sidebar-group-label">User</div>
|
||||
<button
|
||||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing']"
|
||||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing', 'apikeys']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
{{ tab === 'apikeys' ? 'API Keys' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="authStore.isAdmin" class="sidebar-group">
|
||||
@@ -924,6 +1188,69 @@ function formatUserDate(iso: string): string {
|
||||
<span v-if="saved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Model Management -->
|
||||
<section class="settings-section full-width">
|
||||
<div class="model-mgmt-header">
|
||||
<div>
|
||||
<h2>Model Management</h2>
|
||||
<p class="section-desc">Install and remove Ollama models without leaving the app. Search <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a> for available models.</p>
|
||||
</div>
|
||||
<button class="btn-secondary" @click="loadOllamaModels" title="Refresh list">↺ Refresh</button>
|
||||
</div>
|
||||
|
||||
<!-- Installed models -->
|
||||
<div v-if="ollamaModels.length" class="model-list">
|
||||
<div v-for="m in ollamaModels" :key="m.name" class="model-row">
|
||||
<div class="model-row-info">
|
||||
<span class="model-name">{{ m.name }}</span>
|
||||
<span v-if="m.loaded" class="model-badge model-badge--loaded">in VRAM</span>
|
||||
<span v-if="m.name === (defaultModel || defaultChatModel)" class="model-badge model-badge--default">default</span>
|
||||
</div>
|
||||
<div class="model-row-right">
|
||||
<span class="model-size">{{ formatBytes(m.size) }}</span>
|
||||
<button
|
||||
class="model-delete-btn"
|
||||
:disabled="deletingModel === m.name"
|
||||
@click="deleteModel(m.name)"
|
||||
:title="`Remove ${m.name}`"
|
||||
>{{ deletingModel === m.name ? '…' : '✕' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="field-hint">No models installed, or Ollama is unreachable.</p>
|
||||
|
||||
<!-- Pull a model -->
|
||||
<div class="model-pull-form">
|
||||
<input
|
||||
v-model="pullModelName"
|
||||
class="input"
|
||||
placeholder="e.g. qwen3:7b"
|
||||
:disabled="pulling"
|
||||
@keydown.enter="pullModel"
|
||||
/>
|
||||
<button class="btn-secondary" @click="pullModel" :disabled="pulling || !pullModelName.trim()">
|
||||
{{ pulling ? 'Pulling…' : 'Pull' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="model-suggestions">
|
||||
<span class="suggestions-label">Suggestions:</span>
|
||||
<button v-for="s in ['qwen3:7b','qwen3:14b','qwen3:4b','llama3.1:8b','nomic-embed-text']"
|
||||
:key="s" class="suggestion-chip"
|
||||
:disabled="pulling || ollamaModels.some(m => m.name === s)"
|
||||
@click="pullModelName = s"
|
||||
>{{ s }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Pull progress -->
|
||||
<div v-if="pullProgress" class="model-pull-progress">
|
||||
<div class="pull-status">{{ pullProgress.status }}</div>
|
||||
<div class="pull-bar-track" v-if="pullProgress.pct !== null">
|
||||
<div class="pull-bar-fill" :style="{ width: pullProgress.pct + '%' }"></div>
|
||||
</div>
|
||||
<div v-else class="pull-bar-indeterminate"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ── Account ── -->
|
||||
@@ -1356,35 +1683,6 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Timezone -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Timezone</h2>
|
||||
<p class="section-desc">
|
||||
Briefing slots fire at the times below in this timezone.
|
||||
Auto-detected from your browser — override if the server should use a different zone.
|
||||
</p>
|
||||
<div class="briefing-timezone-row">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
v-model="briefingConfig.timezone"
|
||||
placeholder="e.g. America/New_York"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="briefingConfig.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone"
|
||||
>Detect</button>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Use an
|
||||
<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" target="_blank" rel="noopener">IANA timezone name</a>
|
||||
(e.g. <code>Europe/London</code>, <code>America/Chicago</code>).
|
||||
Your browser reports: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Slots -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Scheduled Slots</h2>
|
||||
@@ -1409,33 +1707,88 @@ function formatUserDate(iso: string): string {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="briefingConfig.timezone" class="field-hint" style="margin-top: 0.5rem">
|
||||
Firing in timezone: <strong>{{ briefingConfig.timezone }}</strong>
|
||||
<p class="field-hint" style="margin-top: 0.5rem">
|
||||
Firing in timezone: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- RSS Feeds -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>RSS Feeds</h2>
|
||||
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
|
||||
<div class="briefing-feeds-header">
|
||||
<div>
|
||||
<h2>RSS Feeds</h2>
|
||||
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="briefingFeeds.length"
|
||||
class="btn-secondary briefing-refresh-btn"
|
||||
@click="refreshFeeds"
|
||||
:disabled="refreshingFeeds"
|
||||
title="Fetch latest items from all feeds"
|
||||
>{{ refreshingFeeds ? 'Refreshing…' : 'Refresh all' }}</button>
|
||||
</div>
|
||||
|
||||
<div class="briefing-feeds-list" v-if="briefingFeeds.length">
|
||||
<div class="briefing-feed-row" v-for="feed in briefingFeeds" :key="feed.id">
|
||||
<div class="briefing-feed-info">
|
||||
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
|
||||
<span class="briefing-feed-url">{{ feed.url }}</span>
|
||||
<div class="briefing-feed-title-row">
|
||||
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
|
||||
<span v-if="feed.category" class="briefing-feed-cat">{{ feed.category }}</span>
|
||||
</div>
|
||||
<span v-if="feed.title" class="briefing-feed-url">{{ feed.url }}</span>
|
||||
<span class="briefing-feed-age">{{ feedAge(feed.last_fetched_at) }}</span>
|
||||
</div>
|
||||
<button class="briefing-btn-remove" @click="removeFeed(feed.id)" aria-label="Remove feed">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="field-hint">No feeds yet.</p>
|
||||
<div class="briefing-add-feed">
|
||||
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" style="flex: 1" />
|
||||
<p v-else class="field-hint">No feeds yet. Try adding Reuters, BBC World, or Hacker News.</p>
|
||||
|
||||
<div class="briefing-add-feed-form">
|
||||
<div class="briefing-add-feed-inputs">
|
||||
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" @keydown.enter="addFeed" />
|
||||
<input v-model="newFeedCategory" class="input briefing-cat-input" placeholder="Category (optional)" @keydown.enter="addFeed" />
|
||||
</div>
|
||||
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
|
||||
{{ addingFeed ? 'Adding…' : 'Add Feed' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- News Preferences -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>News Preferences</h2>
|
||||
<p class="section-desc">
|
||||
Tell the briefing what topics you care about. Topics are matched against
|
||||
classified RSS items before each briefing runs.
|
||||
</p>
|
||||
<div class="settings-field">
|
||||
<label class="field-label">Interested in</label>
|
||||
<TagInput
|
||||
:model-value="briefingIncludeTopics"
|
||||
placeholder="e.g. technology, science, local"
|
||||
:fetch-tags="fetchTopicSuggestions"
|
||||
@update:model-value="saveIncludeTopics"
|
||||
/>
|
||||
</div>
|
||||
<div class="settings-field" style="margin-top: 0.75rem">
|
||||
<label class="field-label">Not interested in</label>
|
||||
<TagInput
|
||||
:model-value="briefingExcludeTopics"
|
||||
placeholder="e.g. sports, celebrity"
|
||||
:fetch-tags="fetchTopicSuggestions"
|
||||
@update:model-value="saveExcludeTopics"
|
||||
/>
|
||||
</div>
|
||||
<details class="topic-vocab-hint" style="margin-top: 0.75rem">
|
||||
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.82rem">Standard topic vocabulary</summary>
|
||||
<p class="field-hint" style="margin-top: 0.35rem">
|
||||
technology · science · politics · business · health · environment ·
|
||||
local · entertainment · sports · other
|
||||
</p>
|
||||
<p class="field-hint">Custom terms are also accepted.</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Notifications</h2>
|
||||
@@ -1457,6 +1810,110 @@ function formatUserDate(iso: string): string {
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── API Keys ── -->
|
||||
<div v-show="activeTab === 'apikeys'" class="settings-grid">
|
||||
<section class="settings-section full-width">
|
||||
<h2>API Keys</h2>
|
||||
<p class="settings-description">
|
||||
API keys let external tools (like the Fable MCP server) access your data without a browser session.
|
||||
Write-scoped keys can create and update content; read-only keys can only query.
|
||||
</p>
|
||||
|
||||
<!-- Create form -->
|
||||
<div class="api-key-create-form">
|
||||
<input v-model="newKeyName" placeholder="Key name (e.g. Claude MCP)" class="settings-input" />
|
||||
<div class="api-key-scope-select">
|
||||
<label><input type="radio" v-model="newKeyScope" value="read" /> Read-only</label>
|
||||
<label><input type="radio" v-model="newKeyScope" value="write" /> Read + Write</label>
|
||||
</div>
|
||||
<button @click="createApiKey" :disabled="!newKeyName || creatingApiKey" class="btn btn-primary">
|
||||
{{ creatingApiKey ? 'Generating…' : 'Generate Key' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- One-time key reveal -->
|
||||
<div v-if="newKeyValue" class="api-key-reveal">
|
||||
<p><strong>Copy this key now — it will not be shown again.</strong></p>
|
||||
<div class="api-key-value-row">
|
||||
<code class="api-key-value">{{ newKeyValue }}</code>
|
||||
<button @click="copyApiKey" class="btn btn-secondary btn-sm">{{ apiKeyCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
</div>
|
||||
<div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
|
||||
<button @click="downloadEnvFile" class="btn btn-secondary btn-sm">Download .env</button>
|
||||
<button @click="downloadMcpConfig" class="btn btn-secondary btn-sm">Download Claude config</button>
|
||||
<button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keys table -->
|
||||
<table v-if="apiKeys.length > 0" class="api-keys-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Last Used</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in apiKeys" :key="key.id">
|
||||
<td>{{ key.name }}</td>
|
||||
<td><span :class="['scope-badge', key.scope]">{{ key.scope }}</span></td>
|
||||
<td><code>{{ key.key_prefix }}…</code></td>
|
||||
<td>{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }}</td>
|
||||
<td>
|
||||
<span v-if="revokeConfirmId !== key.id">
|
||||
<button @click="revokeConfirmId = key.id" class="btn btn-secondary btn-sm">Revoke</button>
|
||||
</span>
|
||||
<span v-else>
|
||||
Sure?
|
||||
<button @click="revokeApiKey(key.id)" class="btn btn-danger btn-sm">Yes</button>
|
||||
<button @click="revokeConfirmId = null" class="btn btn-secondary btn-sm">No</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No API keys yet.</p>
|
||||
</section>
|
||||
|
||||
<!-- Fable MCP install section -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Fable MCP</h2>
|
||||
<p class="settings-description">
|
||||
The Fable MCP server lets Claude (and other MCP clients) read and write your notes, tasks, and projects.
|
||||
Install it locally, then point it at this Fable instance with an API key above.
|
||||
</p>
|
||||
|
||||
<div v-if="mcpInfoLoading" class="mcp-status">Checking package availability…</div>
|
||||
<template v-else-if="mcpInfo">
|
||||
<div v-if="mcpInfo.available" class="mcp-available">
|
||||
<div class="mcp-pkg-row">
|
||||
<span class="mcp-pkg-name">{{ mcpInfo.filename }}</span>
|
||||
<a href="/api/fable-mcp/download" class="btn btn-primary btn-sm" download>Download</a>
|
||||
</div>
|
||||
|
||||
<div class="mcp-install-steps">
|
||||
<h3>Installation</h3>
|
||||
<ol>
|
||||
<li>
|
||||
Download the wheel above and install it:
|
||||
<pre class="mcp-code">pip install {{ mcpInfo.filename }}</pre>
|
||||
</li>
|
||||
<li>
|
||||
Create a <code>.env</code> file (or set environment variables):
|
||||
<pre class="mcp-code">FABLE_URL={{ origin }}
|
||||
FABLE_API_KEY=<your-api-key></pre>
|
||||
</li>
|
||||
<li>
|
||||
Add to your Claude MCP config (<code>~/.claude.json</code> or the Claude Desktop config):
|
||||
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mcp-unavailable">
|
||||
<p>The Fable MCP package is not bundled in this image build. It will be available after the next image rebuild.</p>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ── Admin ── -->
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
||||
|
||||
@@ -1806,7 +2263,6 @@ function formatUserDate(iso: string): string {
|
||||
<ul v-if="groupMemberResults.length" class="member-results">
|
||||
<li v-for="u in groupMemberResults" :key="u.id" class="member-result-item" @click="addMemberToGroup(g.id, u)">
|
||||
<span class="member-result-name">{{ u.username }}</span>
|
||||
<span class="member-result-email">{{ u.email }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1927,6 +2383,70 @@ function formatUserDate(iso: string): string {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Model Management ───────────────────────────────────────── */
|
||||
.model-mgmt-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem;
|
||||
}
|
||||
.model-mgmt-header h2 { margin: 0; }
|
||||
.model-mgmt-header .section-desc { margin: 0.25rem 0 0; }
|
||||
.model-list { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }
|
||||
.model-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.model-row-info { display: flex; align-items: center; gap: 0.4rem; min-width: 0; flex: 1; }
|
||||
.model-name { font-size: 0.88rem; font-weight: 500; font-family: monospace; }
|
||||
.model-badge {
|
||||
font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.model-badge--loaded { background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
|
||||
.model-badge--default { background: color-mix(in srgb, var(--color-primary) 18%, transparent); color: var(--color-primary); }
|
||||
.model-row-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||
.model-size { font-size: 0.78rem; color: var(--color-text-muted); }
|
||||
.model-delete-btn {
|
||||
background: none; border: none; cursor: pointer; color: var(--color-text-muted);
|
||||
font-size: 0.8rem; padding: 0.2rem 0.35rem; border-radius: 3px; line-height: 1;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.model-delete-btn:hover:not(:disabled) { color: var(--color-danger, #ef4444); background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent); }
|
||||
.model-delete-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.model-pull-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
||||
.model-pull-form .input { flex: 1; }
|
||||
.model-suggestions { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; }
|
||||
.suggestions-label { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
|
||||
.suggestion-chip {
|
||||
font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 4px;
|
||||
border: 1px solid var(--color-border); background: var(--color-bg-card);
|
||||
cursor: pointer; font-family: monospace; color: var(--color-text);
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.suggestion-chip:hover:not(:disabled) { border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card)); }
|
||||
.suggestion-chip:disabled { opacity: 0.4; cursor: default; }
|
||||
.model-pull-progress { margin-top: 0.6rem; }
|
||||
.pull-status { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.25rem; }
|
||||
.pull-bar-track {
|
||||
height: 4px; background: var(--color-border); border-radius: 2px; overflow: hidden;
|
||||
}
|
||||
.pull-bar-fill {
|
||||
height: 100%; background: var(--color-primary); border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.pull-bar-indeterminate {
|
||||
height: 4px; background: var(--color-border); border-radius: 2px;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.pull-bar-indeterminate::after {
|
||||
content: ""; position: absolute; top: 0; left: -40%;
|
||||
width: 40%; height: 100%; background: var(--color-primary); border-radius: 2px;
|
||||
animation: indeterminate 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes indeterminate {
|
||||
0% { left: -40%; } 100% { left: 100%; }
|
||||
}
|
||||
|
||||
/* Assistant — 2-col internal grid */
|
||||
.assistant-grid {
|
||||
display: grid;
|
||||
@@ -2505,7 +3025,7 @@ function formatUserDate(iso: string): string {
|
||||
.version-line {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
@@ -2515,9 +3035,9 @@ function formatUserDate(iso: string): string {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Groups tab ──────────────────────────────────────────────── */
|
||||
@@ -2752,12 +3272,6 @@ function formatUserDate(iso: string): string {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.briefing-timezone-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.briefing-unit-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
@@ -2870,10 +3384,148 @@ function formatUserDate(iso: string): string {
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.briefing-btn-remove:hover { color: var(--color-danger, #ef4444); }
|
||||
.briefing-feeds-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.briefing-feeds-header h2 { margin: 0; }
|
||||
.briefing-feeds-header .section-desc { margin: 0.25rem 0 0; }
|
||||
.briefing-refresh-btn { white-space: nowrap; flex-shrink: 0; }
|
||||
.briefing-feed-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.briefing-feed-cat {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.briefing-feed-age {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.briefing-add-feed-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.briefing-add-feed-inputs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.briefing-add-feed-inputs .input { flex: 1; min-width: 160px; }
|
||||
.briefing-cat-input { max-width: 180px; }
|
||||
.briefing-add-feed {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* API Keys tab */
|
||||
.api-key-create-form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.api-key-scope-select {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.api-key-scope-select label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.api-key-reveal {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.api-key-value-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.api-key-value {
|
||||
flex: 1;
|
||||
background: var(--color-surface-2, var(--color-surface));
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.api-keys-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.api-keys-table th, .api-keys-table td {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.api-keys-table th { font-weight: 600; opacity: 0.7; }
|
||||
.scope-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
|
||||
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
|
||||
.settings-empty { opacity: 0.5; margin-top: 1rem; }
|
||||
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
|
||||
|
||||
/* Fable MCP section */
|
||||
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
|
||||
.mcp-unavailable p { opacity: 0.7; }
|
||||
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||
.mcp-pkg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.mcp-pkg-name { font-family: monospace; font-size: 0.9rem; flex: 1; }
|
||||
.mcp-install-steps h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.75rem; }
|
||||
.mcp-install-steps ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.mcp-install-steps li { line-height: 1.6; font-size: 0.9rem; }
|
||||
.mcp-code {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--color-text) 6%, transparent);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
||||
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
||||
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
||||
import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||
import type { Note } from "@/types/note";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
@@ -23,6 +24,7 @@ import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||||
import RecurrenceEditor from "@/components/RecurrenceEditor.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -40,6 +42,9 @@ const projectId = ref<number | null>(null);
|
||||
const milestoneId = ref<number | null>(null);
|
||||
const parentId = ref<number | null>(null);
|
||||
const parentTitle = ref("");
|
||||
const startedAt = ref<string | null>(null);
|
||||
const completedAt = ref<string | null>(null);
|
||||
const recurrenceRule = ref<Record<string, unknown> | null>(null);
|
||||
const parentSearchQuery = ref("");
|
||||
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
|
||||
const parentSearchLoading = ref(false);
|
||||
@@ -274,6 +279,10 @@ onMounted(async () => {
|
||||
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
||||
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
||||
parentSearchQuery.value = parentTitle.value;
|
||||
const noteTask = store.currentTask as unknown as Note;
|
||||
startedAt.value = noteTask.started_at ?? null;
|
||||
completedAt.value = noteTask.completed_at ?? null;
|
||||
recurrenceRule.value = noteTask.recurrence_rule ?? null;
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
@@ -309,6 +318,7 @@ async function save() {
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
parent_id: parentId.value,
|
||||
recurrence_rule: recurrenceRule.value,
|
||||
};
|
||||
if (isEditing.value) {
|
||||
await store.updateTask(taskId.value!, data);
|
||||
@@ -373,6 +383,7 @@ async function doAutoSave() {
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
parent_id: parentId.value,
|
||||
recurrence_rule: recurrenceRule.value,
|
||||
} as Record<string, unknown>);
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
@@ -483,8 +494,19 @@ useEditorGuards(dirty, save);
|
||||
<option value="todo">Todo</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="startedAt || completedAt" class="sb-timestamps">
|
||||
<div v-if="startedAt" class="sb-timestamp">
|
||||
<span class="sb-ts-label">Started</span>
|
||||
<span class="sb-ts-value">{{ new Date(startedAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="completedAt" class="sb-timestamp">
|
||||
<span class="sb-ts-label">Completed</span>
|
||||
<span class="sb-ts-value">{{ new Date(completedAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Priority</label>
|
||||
<select v-model="priority" @change="markDirty" class="sb-select">
|
||||
@@ -498,6 +520,10 @@ useEditorGuards(dirty, save);
|
||||
<label class="sb-label">Due Date</label>
|
||||
<input v-model="dueDate" type="date" class="sb-input" @input="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Recurrence</label>
|
||||
<RecurrenceEditor v-model="recurrenceRule" @update:modelValue="markDirty" />
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Project</label>
|
||||
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
|
||||
@@ -903,6 +929,28 @@ useEditorGuards(dirty, save);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Lifecycle timestamps */
|
||||
.sb-timestamps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.sb-timestamp {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.sb-ts-label {
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-ts-value {
|
||||
color: var(--color-text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Narrow screen: sidebar collapses */
|
||||
@media (max-width: 720px) {
|
||||
.task-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed, ref, watch } from "vue";
|
||||
import { onMounted, onUnmounted, computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
@@ -33,12 +33,14 @@ const statusCycle: Record<TaskStatus, TaskStatus> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: "todo",
|
||||
cancelled: "todo",
|
||||
};
|
||||
|
||||
const statusDotClass: Record<TaskStatus, string> = {
|
||||
todo: "dot-todo",
|
||||
in_progress: "dot-in-progress",
|
||||
done: "dot-done",
|
||||
cancelled: "dot-cancelled",
|
||||
};
|
||||
|
||||
function cycleSubTaskStatus(subTask: Note) {
|
||||
@@ -94,7 +96,27 @@ async function loadTask(id: number) {
|
||||
if (bl.status === "fulfilled") backlinks.value = bl.value;
|
||||
}
|
||||
|
||||
onMounted(() => loadTask(taskId.value));
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
e.stopPropagation(); // prevent App.vue's global handler from also firing
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (active && active !== document.body) {
|
||||
(active as HTMLElement).blur();
|
||||
return;
|
||||
}
|
||||
if (store.currentTask?.project_id) {
|
||||
router.push(`/projects/${store.currentTask.project_id}`);
|
||||
} else {
|
||||
router.push("/tasks");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTask(taskId.value);
|
||||
// Capture phase so this fires before App.vue's document-level handler
|
||||
window.addEventListener("keydown", handleKeydown, true);
|
||||
});
|
||||
onUnmounted(() => window.removeEventListener("keydown", handleKeydown, true));
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) loadTask(Number(newId));
|
||||
@@ -117,8 +139,25 @@ const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: null,
|
||||
cancelled: null,
|
||||
};
|
||||
|
||||
function recurrenceSummary(rule: Record<string, unknown> | null): string | null {
|
||||
if (!rule) return null;
|
||||
if (rule.type === "interval") {
|
||||
return `Every ${rule.every} ${rule.unit}(s)`;
|
||||
}
|
||||
if (rule.type === "calendar") {
|
||||
if (rule.unit === "month") return `Monthly on day ${rule.day_of_month}`;
|
||||
if (rule.unit === "year") {
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
const m = months[((rule.month as number) ?? 1) - 1];
|
||||
return `Yearly on ${m} ${rule.day_of_month}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const advanceLabel = computed(() => {
|
||||
const s = store.currentTask?.status as TaskStatus | undefined;
|
||||
if (!s) return null;
|
||||
@@ -221,7 +260,10 @@ const subTaskProgress = computed(() => {
|
||||
</div>
|
||||
<template v-else-if="store.currentTask">
|
||||
<div class="toolbar">
|
||||
<router-link to="/tasks" class="btn-back">← Tasks</router-link>
|
||||
<router-link
|
||||
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
|
||||
class="btn-back"
|
||||
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
|
||||
<router-link
|
||||
:to="`/tasks/${store.currentTask.id}/edit`"
|
||||
class="btn-edit"
|
||||
@@ -295,6 +337,17 @@ const subTaskProgress = computed(() => {
|
||||
Due: {{ store.currentTask.due_date }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-meta-row" v-if="store.currentTask.started_at || store.currentTask.completed_at || store.currentTask.recurrence_rule">
|
||||
<span v-if="store.currentTask.started_at" class="task-meta-item">
|
||||
Started: {{ new Date(store.currentTask.started_at).toLocaleString() }}
|
||||
</span>
|
||||
<span v-if="store.currentTask.completed_at" class="task-meta-item">
|
||||
Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }}
|
||||
</span>
|
||||
<span v-if="recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null)" class="task-meta-item task-meta-recurrence">
|
||||
↻ {{ recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tags" v-if="store.currentTask.tags.length">
|
||||
<TagPill
|
||||
v-for="tag in store.currentTask.tags"
|
||||
@@ -528,6 +581,20 @@ const subTaskProgress = computed(() => {
|
||||
color: var(--color-overdue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.task-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.task-meta-item {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.task-meta-recurrence {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -614,6 +681,9 @@ const subTaskProgress = computed(() => {
|
||||
.dot-done {
|
||||
background: var(--color-status-done, #22c55e);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
.sub-title {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import type { Task, TaskStatus } from "@/types/task";
|
||||
import type { Task, TaskStatus, TaskPriority } from "@/types/task";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
||||
import SearchBar from "@/components/SearchBar.vue";
|
||||
@@ -122,7 +122,8 @@ onMounted(async () => {
|
||||
store.activeTagFilters = tags;
|
||||
}
|
||||
if (route.query.status) {
|
||||
store.statusFilter = route.query.status as TaskStatus;
|
||||
const qs = route.query.status;
|
||||
store.statusFilter = (Array.isArray(qs) ? qs : [qs]).filter(Boolean) as TaskStatus[];
|
||||
}
|
||||
store.limit = viewMode.value === "grouped" ? 100 : 200;
|
||||
collapsedGroups.value.add("done");
|
||||
@@ -150,14 +151,20 @@ function onSearch(q: string) {
|
||||
store.setSearch(q);
|
||||
}
|
||||
|
||||
function onStatusFilterChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
store.setStatusFilter(value as TaskStatus | "");
|
||||
function toggleStatusChip(value: TaskStatus) {
|
||||
const current = [...store.statusFilter];
|
||||
const idx = current.indexOf(value);
|
||||
if (idx === -1) current.push(value);
|
||||
else current.splice(idx, 1);
|
||||
store.setStatusFilter(current);
|
||||
}
|
||||
|
||||
function onPriorityFilterChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
store.setPriorityFilter(value as any);
|
||||
function togglePriorityChip(value: TaskPriority) {
|
||||
const current = [...store.priorityFilter];
|
||||
const idx = current.indexOf(value);
|
||||
if (idx === -1) current.push(value);
|
||||
else current.splice(idx, 1);
|
||||
store.setPriorityFilter(current);
|
||||
}
|
||||
|
||||
function onTagClick(tag: string) {
|
||||
@@ -210,19 +217,20 @@ function toggleGroup(key: string) {
|
||||
|
||||
<div class="controls">
|
||||
<div class="filter-controls">
|
||||
<select :value="store.statusFilter" @change="onStatusFilterChange" class="filter-select">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="todo">Todo</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
<select :value="store.priorityFilter" @change="onPriorityFilterChange" class="filter-select">
|
||||
<option value="">All Priorities</option>
|
||||
<option value="none">None</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<div class="filter-chip-group">
|
||||
<button v-for="s in (['todo', 'in_progress', 'done', 'cancelled'] as TaskStatus[])" :key="s"
|
||||
:class="['filter-chip', { active: store.statusFilter.includes(s) }]"
|
||||
@click="toggleStatusChip(s)">
|
||||
{{ { todo: 'Todo', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled' }[s] }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-chip-group">
|
||||
<button v-for="p in (['low', 'medium', 'high'] as TaskPriority[])" :key="p"
|
||||
:class="['filter-chip', { active: store.priorityFilter.includes(p) }]"
|
||||
@click="togglePriorityChip(p)">
|
||||
{{ p.charAt(0).toUpperCase() + p.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-controls">
|
||||
<div class="sort-controls">
|
||||
@@ -270,7 +278,7 @@ function toggleGroup(key: string) {
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.tasks.length === 0" class="empty-state">
|
||||
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter || store.priorityFilter">
|
||||
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter.length || store.priorityFilter.length">
|
||||
<p class="empty-title">No tasks match your filters</p>
|
||||
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
|
||||
</template>
|
||||
@@ -382,8 +390,29 @@ function toggleGroup(key: string) {
|
||||
}
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.filter-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.filter-chip {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-input-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.filter-chip.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.right-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bump the patch segment of fable-mcp/pyproject.toml version and stage the file.
|
||||
# Usage: called automatically by the Claude Code pre-commit hook, or manually.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
FILE="$REPO_ROOT/fable-mcp/pyproject.toml"
|
||||
|
||||
current=$(grep '^version = ' "$FILE" | sed 's/version = "\(.*\)"/\1/')
|
||||
major=$(echo "$current" | cut -d. -f1)
|
||||
minor=$(echo "$current" | cut -d. -f2)
|
||||
patch=$(echo "$current" | cut -d. -f3)
|
||||
new_version="$major.$minor.$((patch + 1))"
|
||||
|
||||
sed -i "s/^version = \"$current\"/version = \"$new_version\"/" "$FILE"
|
||||
git -C "$REPO_ROOT" add "$FILE"
|
||||
echo "fable-mcp: $current → $new_version"
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Claude Code PreToolUse hook for Bash.
|
||||
# Reads the tool input JSON from stdin; if the command is a git commit
|
||||
# and fable-mcp files (other than pyproject.toml) are staged, bumps
|
||||
# the fable-mcp patch version before the commit proceeds.
|
||||
#
|
||||
# Exits 0 always so it never blocks the commit.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="/home/bvandeusen/Nextcloud/Projects/fabledassistant"
|
||||
|
||||
input=$(cat)
|
||||
command=$(echo "$input" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
# Claude Code sends {tool_input: {command: ...}}
|
||||
ti = data.get('tool_input', data)
|
||||
print(ti.get('command', ''))
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
# Only act on git commit commands
|
||||
if ! echo "$command" | grep -qE "git commit"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Check if fable-mcp files other than pyproject.toml are staged
|
||||
fable_staged=$(git diff --cached --name-only 2>/dev/null \
|
||||
| grep "^fable-mcp/" \
|
||||
| grep -v "^fable-mcp/pyproject.toml$" \
|
||||
|| true)
|
||||
|
||||
if [ -n "$fable_staged" ]; then
|
||||
bash "$REPO_ROOT/scripts/bump_fable_mcp_version.sh"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -20,6 +20,7 @@ from fabledassistant.routes.milestones import milestones_bp
|
||||
from fabledassistant.routes.task_logs import task_logs_bp
|
||||
from fabledassistant.routes.projects import projects_bp
|
||||
from fabledassistant.routes.push import push_bp
|
||||
from fabledassistant.routes.fable_mcp_dist import fable_mcp_dist_bp
|
||||
from fabledassistant.routes.quick_capture import quick_capture_bp
|
||||
from fabledassistant.routes.settings import settings_bp
|
||||
from fabledassistant.routes.tasks import tasks_bp
|
||||
@@ -27,6 +28,9 @@ from fabledassistant.routes.groups import groups_bp
|
||||
from fabledassistant.routes.shares import shares_bp
|
||||
from fabledassistant.routes.in_app_notifications import notifications_bp
|
||||
from fabledassistant.routes.users import users_bp
|
||||
from fabledassistant.routes.api_keys import api_keys_bp
|
||||
from fabledassistant.routes.events import events_bp
|
||||
from fabledassistant.routes.search import search_bp
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -58,6 +62,13 @@ def create_app() -> Quart:
|
||||
"Set SECRET_KEY or SECRET_KEY_FILE for production use."
|
||||
)
|
||||
|
||||
if not Config.TRUST_PROXY_HEADERS:
|
||||
logger.warning(
|
||||
"TRUST_PROXY_HEADERS is not set. If this instance is behind a reverse proxy "
|
||||
"(nginx, Caddy, Traefik) set TRUST_PROXY_HEADERS=true so rate limiting uses "
|
||||
"real client IPs rather than the proxy IP."
|
||||
)
|
||||
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(api)
|
||||
app.register_blueprint(auth_bp)
|
||||
@@ -67,6 +78,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(images_bp)
|
||||
app.register_blueprint(milestones_bp)
|
||||
app.register_blueprint(notes_bp)
|
||||
app.register_blueprint(fable_mcp_dist_bp)
|
||||
app.register_blueprint(projects_bp)
|
||||
app.register_blueprint(push_bp)
|
||||
app.register_blueprint(quick_capture_bp)
|
||||
@@ -77,6 +89,9 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(shares_bp)
|
||||
app.register_blueprint(notifications_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(api_keys_bp)
|
||||
app.register_blueprint(events_bp)
|
||||
app.register_blueprint(search_bp)
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
@@ -237,12 +252,17 @@ def create_app() -> Quart:
|
||||
await backfill_note_embeddings()
|
||||
except Exception:
|
||||
logger.warning("Embedding backfill failed", exc_info=True)
|
||||
try:
|
||||
from fabledassistant.services.projects import backfill_project_summaries
|
||||
await backfill_project_summaries()
|
||||
except Exception:
|
||||
logger.warning("Project summary backfill failed", exc_info=True)
|
||||
|
||||
asyncio.create_task(_delayed_backfill())
|
||||
|
||||
# Start briefing scheduler
|
||||
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
|
||||
start_briefing_scheduler(asyncio.get_event_loop())
|
||||
await start_briefing_scheduler(asyncio.get_running_loop())
|
||||
|
||||
@app.after_serving
|
||||
async def shutdown():
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import functools
|
||||
|
||||
from quart import g, jsonify, session
|
||||
from quart import g, jsonify, request, session
|
||||
|
||||
from fabledassistant.services.auth import get_user_by_id
|
||||
from fabledassistant.services.api_keys import lookup_key
|
||||
|
||||
|
||||
def _check_auth(f, required_role: str | None = None):
|
||||
@functools.wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
# --- Bearer token path ---
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
raw_key = auth_header[len("Bearer "):]
|
||||
api_key = await lookup_key(raw_key)
|
||||
if api_key is None:
|
||||
return jsonify({"error": "Invalid or revoked API key"}), 401
|
||||
user = await get_user_by_id(api_key.user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 401
|
||||
# Scope enforcement: read-only keys cannot mutate
|
||||
if api_key.scope == "read" and request.method not in ("GET", "HEAD", "OPTIONS"):
|
||||
return jsonify({"error": "Read-only key cannot perform write operations"}), 403
|
||||
# Role check (admin_required routes)
|
||||
if required_role and user.role != required_role:
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
g.user = user
|
||||
g.api_key = api_key
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
# --- Session path (unchanged) ---
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
|
||||
@@ -88,5 +88,10 @@ class Config:
|
||||
errors.append(f"SMTP_PORT={cls.SMTP_PORT} must be between 1 and 65535")
|
||||
if cls.oidc_enabled() and not cls.BASE_URL.startswith(("http://", "https://")):
|
||||
errors.append(f"BASE_URL='{cls.BASE_URL}' must start with http:// or https:// when OIDC is enabled")
|
||||
if cls.SECRET_KEY == "dev-secret-change-me" and cls.SECURE_COOKIES:
|
||||
errors.append(
|
||||
"SECRET_KEY is set to the insecure default but SECURE_COOKIES=true indicates "
|
||||
"a production deployment. Set SECRET_KEY or SECRET_KEY_FILE before starting."
|
||||
)
|
||||
if errors:
|
||||
raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors))
|
||||
|
||||
@@ -40,3 +40,4 @@ from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402,
|
||||
from fabledassistant.models.notification import Notification # noqa: E402, F401
|
||||
from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401
|
||||
from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401
|
||||
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import CreatedAtMixin
|
||||
|
||||
|
||||
class ApiKey(Base, CreatedAtMixin):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
key_prefix: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
scope: Mapped[str] = mapped_column(Text, nullable=False) # "read" or "write"
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_api_keys_user_id", "user_id"),
|
||||
Index("ix_api_keys_key_hash", "key_hash"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"key_prefix": self.key_prefix,
|
||||
"scope": self.scope,
|
||||
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None,
|
||||
}
|
||||
@@ -23,6 +23,8 @@ class Conversation(Base, TimestampMixin):
|
||||
conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat")
|
||||
# For briefing conversations only: the calendar date this briefing covers.
|
||||
briefing_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
|
||||
# NULL = orphan notes only; -1 = all notes; positive int = specific project
|
||||
rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
|
||||
|
||||
messages: Mapped[list["Message"]] = relationship(
|
||||
back_populates="conversation",
|
||||
@@ -47,6 +49,7 @@ class Conversation(Base, TimestampMixin):
|
||||
"model": self.model,
|
||||
"conversation_type": self.conversation_type,
|
||||
"briefing_date": self.briefing_date.isoformat() if self.briefing_date else None,
|
||||
"rag_project_id": self.rag_project_id,
|
||||
"message_count": msg_count,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
@@ -67,6 +70,9 @@ class Message(Base, CreatedAtMixin):
|
||||
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
||||
# 'metadata' is reserved by SQLAlchemy Declarative — use msg_metadata as the
|
||||
# Python attribute name, mapped to the 'metadata' DB column.
|
||||
msg_metadata: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
|
||||
|
||||
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
|
||||
|
||||
@@ -83,5 +89,6 @@ class Message(Base, CreatedAtMixin):
|
||||
"status": self.status,
|
||||
"context_note_id": self.context_note_id,
|
||||
"tool_calls": self.tool_calls,
|
||||
"metadata": self.msg_metadata,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ class Event(Base):
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
location: Mapped[str] = mapped_column(Text, default="")
|
||||
caldav_uid: Mapped[str] = mapped_column(Text, default="")
|
||||
color: Mapped[str] = mapped_column(Text, default="")
|
||||
recurrence: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
@@ -37,7 +39,9 @@ class Event(Base):
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
"uid": self.uid,
|
||||
"caldav_uid": self.caldav_uid,
|
||||
"project_id": self.project_id,
|
||||
"title": self.title,
|
||||
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
|
||||
@@ -45,7 +49,8 @@ class Event(Base):
|
||||
"all_day": self.all_day,
|
||||
"description": self.description,
|
||||
"location": self.location,
|
||||
"color": self.color,
|
||||
"recurrence": self.recurrence,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import enum
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Index, Integer, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
@@ -13,6 +13,7 @@ class TaskStatus(str, enum.Enum):
|
||||
todo = "todo"
|
||||
in_progress = "in_progress"
|
||||
done = "done"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
@@ -44,6 +45,12 @@ class Note(Base, TimestampMixin):
|
||||
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
recurrence_rule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
||||
@@ -70,6 +77,14 @@ class Note(Base, TimestampMixin):
|
||||
"status": self.status,
|
||||
"priority": self.priority,
|
||||
"due_date": self.due_date.isoformat() if self.due_date else None,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"recurrence_rule": self.recurrence_rule,
|
||||
"recurrence_next_spawn_at": (
|
||||
self.recurrence_next_spawn_at.isoformat()
|
||||
if self.recurrence_next_spawn_at
|
||||
else None
|
||||
),
|
||||
"is_task": self.is_task,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import enum
|
||||
from sqlalchemy import ForeignKey, Integer, Text
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from fabledassistant.models import Base
|
||||
from fabledassistant.models.base import TimestampMixin
|
||||
@@ -20,6 +21,10 @@ class Project(Base, TimestampMixin):
|
||||
goal: Mapped[str] = mapped_column(Text, default="")
|
||||
status: Mapped[str] = mapped_column(Text, default="active")
|
||||
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
|
||||
auto_summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||
summary_updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, default=None
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy import ARRAY, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
@@ -49,6 +49,12 @@ class RssItem(Base):
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
topics: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(Text), nullable=False, default=list, server_default="{}"
|
||||
)
|
||||
classified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
feed: Mapped["RssFeed"] = relationship(back_populates="items")
|
||||
|
||||
@@ -67,4 +73,6 @@ class RssItem(Base):
|
||||
"url": self.url,
|
||||
"published_at": self.published_at.isoformat() if self.published_at else None,
|
||||
"content": self.content,
|
||||
"topics": self.topics or [],
|
||||
"classified_at": self.classified_at.isoformat() if self.classified_at else None,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
"""In-process sliding-window rate limiter.
|
||||
|
||||
IMPORTANT — deployment note:
|
||||
Rate limit counters are stored in memory and are lost on process restart.
|
||||
When deployed behind a reverse proxy (nginx, Caddy, Traefik) you MUST set
|
||||
TRUST_PROXY_HEADERS=true so that the real client IP is used as the bucket key
|
||||
rather than the proxy's IP (which would cause all users to share one bucket).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key
|
||||
|
||||
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
|
||||
|
||||
|
||||
@api_keys_bp.route("", methods=["GET"])
|
||||
@login_required
|
||||
async def list_keys_route():
|
||||
uid = get_current_user_id()
|
||||
keys = await list_api_keys(uid)
|
||||
return jsonify({"api_keys": keys})
|
||||
|
||||
|
||||
@api_keys_bp.route("", methods=["POST"])
|
||||
@login_required
|
||||
async def create_key_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
scope = (data.get("scope") or "").strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
if scope not in ("read", "write"):
|
||||
return jsonify({"error": "scope must be 'read' or 'write'"}), 400
|
||||
|
||||
full_key, key_dict = await create_api_key(uid, name=name, scope=scope)
|
||||
# Return the full key ONCE — it is never retrievable again
|
||||
return jsonify({"key": full_key, "api_key": key_dict}), 201
|
||||
|
||||
|
||||
@api_keys_bp.route("/<int:key_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def revoke_key_route(key_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await revoke_api_key(uid, key_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "API key not found"}), 404
|
||||
return "", 204
|
||||
@@ -359,7 +359,10 @@ async def oauth_callback():
|
||||
return redirect("/login?error=oauth")
|
||||
|
||||
sub = claims.get("sub", "")
|
||||
email = claims.get("email", "")
|
||||
# Only trust the email claim for account linking if the provider has verified it.
|
||||
# An unverified email could be used to hijack an existing local account.
|
||||
email_verified = claims.get("email_verified", False)
|
||||
email = claims.get("email", "") if email_verified else ""
|
||||
preferred_username = claims.get("preferred_username", "")
|
||||
|
||||
if not sub:
|
||||
|
||||
@@ -44,9 +44,10 @@ async def get_config():
|
||||
async def put_config():
|
||||
data = await request.get_json()
|
||||
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
|
||||
# Live-patch the scheduler so the new timezone takes effect immediately.
|
||||
# Live-patch the scheduler using the stored user_timezone.
|
||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
||||
update_user_schedule(g.user.id, data)
|
||||
tz_override = await get_setting(g.user.id, "user_timezone") or None
|
||||
update_user_schedule(g.user.id, data, tz_override=tz_override)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@@ -70,6 +71,12 @@ async def add_feed():
|
||||
url = (data.get("url") or "").strip()
|
||||
if not url:
|
||||
return jsonify({"error": "url required"}), 400
|
||||
scheme = url.split("://")[0].lower() if "://" in url else ""
|
||||
if scheme not in ("http", "https"):
|
||||
return jsonify({"error": "Feed URL must use http or https"}), 400
|
||||
from fabledassistant.services.llm import _is_private_url
|
||||
if _is_private_url(url):
|
||||
return jsonify({"error": "Feed URL must not point to an internal address"}), 400
|
||||
category = data.get("category") or None
|
||||
|
||||
async with async_session() as session:
|
||||
@@ -128,8 +135,21 @@ async def recent_items():
|
||||
@briefing_bp.route("/weather", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def get_weather():
|
||||
data = await weather_svc.get_cached_weather(g.user.id)
|
||||
return jsonify({"locations": data})
|
||||
rows = await weather_svc.get_cached_weather_rows(g.user.id)
|
||||
import json as _json
|
||||
raw = await get_setting(g.user.id, "briefing_config", "{}")
|
||||
try:
|
||||
_cfg = _json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
temp_unit = _cfg.get("temp_unit", "C")
|
||||
if temp_unit not in ("C", "F"):
|
||||
temp_unit = "C"
|
||||
except Exception:
|
||||
temp_unit = "C"
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
return jsonify({"locations": cards, "temp_unit": temp_unit})
|
||||
|
||||
|
||||
@briefing_bp.route("/weather/geocode", methods=["POST"])
|
||||
@@ -232,6 +252,147 @@ async def manual_trigger():
|
||||
|
||||
model = await get_setting(g.user.id, "default_model", "")
|
||||
conv = await get_or_create_today_conversation(g.user.id, model)
|
||||
text = await run_compilation(g.user.id, slot, model)
|
||||
msg = await post_message(conv.id, "assistant", text)
|
||||
text, metadata = await run_compilation(g.user.id, slot, model)
|
||||
msg = await post_message(conv.id, "assistant", text, metadata=metadata)
|
||||
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
|
||||
|
||||
|
||||
# ── RSS Reactions ──────────────────────────────────────────────────────────────
|
||||
|
||||
@briefing_bp.route("/rss-reactions", methods=["POST"])
|
||||
@_REQUIRE
|
||||
async def upsert_rss_reaction():
|
||||
"""Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips."""
|
||||
data = await request.get_json()
|
||||
rss_item_id = data.get("rss_item_id")
|
||||
reaction = data.get("reaction")
|
||||
|
||||
if not rss_item_id or reaction not in ("up", "down"):
|
||||
return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400
|
||||
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
async with async_session() as session:
|
||||
# Ownership check: verify item belongs to a feed owned by this user
|
||||
result = await session.execute(
|
||||
_text("""
|
||||
SELECT i.id FROM rss_items i
|
||||
JOIN rss_feeds f ON f.id = i.feed_id
|
||||
WHERE i.id = :item_id AND f.user_id = :uid
|
||||
""").bindparams(item_id=rss_item_id, uid=g.user.id)
|
||||
)
|
||||
if result.first() is None:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
# Check existing reaction
|
||||
existing = await session.execute(
|
||||
_text("""
|
||||
SELECT id, reaction FROM rss_item_reactions
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(uid=g.user.id, item_id=rss_item_id)
|
||||
)
|
||||
row = existing.first()
|
||||
|
||||
if row is None:
|
||||
await session.execute(
|
||||
_text("""
|
||||
INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction)
|
||||
VALUES (:uid, :item_id, :reaction)
|
||||
""").bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction)
|
||||
)
|
||||
action = "created"
|
||||
elif row.reaction == reaction:
|
||||
# Toggle off (same reaction clicked again)
|
||||
await session.execute(
|
||||
_text("""
|
||||
DELETE FROM rss_item_reactions
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(uid=g.user.id, item_id=rss_item_id)
|
||||
)
|
||||
action = "removed"
|
||||
else:
|
||||
# Flip to opposite reaction
|
||||
await session.execute(
|
||||
_text("""
|
||||
UPDATE rss_item_reactions SET reaction = :reaction
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id)
|
||||
)
|
||||
action = "updated"
|
||||
|
||||
await session.commit()
|
||||
|
||||
return jsonify({"ok": True, "action": action})
|
||||
|
||||
|
||||
@briefing_bp.route("/rss-reactions/<int:item_id>", methods=["DELETE"])
|
||||
@_REQUIRE
|
||||
async def delete_rss_reaction(item_id: int):
|
||||
"""Explicitly remove a reaction (useful for MCP/external API callers)."""
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
_text("""
|
||||
DELETE FROM rss_item_reactions
|
||||
WHERE user_id = :uid AND rss_item_id = :item_id
|
||||
""").bindparams(uid=g.user.id, item_id=item_id)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@briefing_bp.route("/news", methods=["GET"])
|
||||
@_REQUIRE
|
||||
async def list_news():
|
||||
"""Return recent RSS articles with optional feed filter and pagination.
|
||||
|
||||
Query params:
|
||||
days — lookback window (default 2, max 90)
|
||||
limit — items per page (default 40, max 100)
|
||||
offset — pagination offset (default 0)
|
||||
feed_id — optional integer filter by feed
|
||||
"""
|
||||
from sqlalchemy import text as _text
|
||||
|
||||
days = min(int(request.args.get("days", 2)), 90)
|
||||
limit = min(int(request.args.get("limit", 40)), 100)
|
||||
offset = max(int(request.args.get("offset", 0)), 0)
|
||||
feed_id = request.args.get("feed_id", type=int)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
_text("""
|
||||
SELECT
|
||||
i.id, i.title, i.url, i.content, i.published_at,
|
||||
i.topics, f.title AS feed_title,
|
||||
r.reaction
|
||||
FROM rss_items i
|
||||
JOIN rss_feeds f ON f.id = i.feed_id
|
||||
LEFT JOIN rss_item_reactions r
|
||||
ON r.rss_item_id = i.id AND r.user_id = :uid
|
||||
WHERE f.user_id = :uid
|
||||
AND (CAST(:feed_id AS integer) IS NULL OR f.id = CAST(:feed_id AS integer))
|
||||
AND COALESCE(i.published_at, i.fetched_at) >= NOW() - make_interval(days => :days)
|
||||
ORDER BY COALESCE(i.published_at, i.fetched_at) DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""").bindparams(uid=g.user.id, days=days, limit=limit,
|
||||
offset=offset, feed_id=feed_id)
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"url": r["url"],
|
||||
"snippet": (r["content"] or "")[:300],
|
||||
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
|
||||
"topics": r["topics"] or [],
|
||||
"source": r["feed_title"],
|
||||
"reaction": r["reaction"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return jsonify({"items": items, "offset": offset, "limit": limit})
|
||||
|
||||
@@ -5,8 +5,8 @@ import logging
|
||||
import httpx
|
||||
from quart import Blueprint, Response, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.routes.utils import not_found
|
||||
from fabledassistant.auth import admin_required, login_required, get_current_user_id
|
||||
from fabledassistant.routes.utils import not_found, parse_pagination
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.chat import (
|
||||
add_message,
|
||||
@@ -38,8 +38,7 @@ chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
|
||||
@login_required
|
||||
async def list_conversations_route():
|
||||
uid = get_current_user_id()
|
||||
limit = min(request.args.get("limit", 50, type=int), 500)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
limit, offset = parse_pagination()
|
||||
conv_type = request.args.get("type", "chat")
|
||||
# Apply retention policy before returning list
|
||||
retention_str = await get_setting(uid, "chat_retention_days", "90")
|
||||
@@ -75,7 +74,11 @@ async def create_conversation_route():
|
||||
data = await request.get_json(force=True, silent=True) or {}
|
||||
title = data.get("title", "")
|
||||
model = data.get("model", Config.OLLAMA_MODEL)
|
||||
conv = await create_conversation(uid, title=title, model=model)
|
||||
conversation_type = data.get("conversation_type", "chat")
|
||||
# Only allow known types to prevent accidental misuse
|
||||
if conversation_type not in ("chat", "mcp"):
|
||||
conversation_type = "chat"
|
||||
conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type)
|
||||
return jsonify(conv.to_dict()), 201
|
||||
|
||||
|
||||
@@ -111,13 +114,15 @@ async def delete_conversation_route(conv_id: int):
|
||||
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_conversation_route(conv_id: int):
|
||||
from fabledassistant.services.chat import _UNSET
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
title = data.get("title")
|
||||
model = data.get("model")
|
||||
if title is None and model is None:
|
||||
return jsonify({"error": "title or model is required"}), 400
|
||||
conv = await update_conversation(uid, conv_id, title=title, model=model)
|
||||
rag_project_id = data.get("rag_project_id", _UNSET)
|
||||
if title is None and model is None and rag_project_id is _UNSET:
|
||||
return jsonify({"error": "title, model, or rag_project_id is required"}), 400
|
||||
conv = await update_conversation(uid, conv_id, title=title, model=model, rag_project_id=rag_project_id)
|
||||
if conv is None:
|
||||
return not_found("Conversation")
|
||||
return jsonify(conv.to_dict())
|
||||
@@ -142,6 +147,9 @@ async def send_message_route(conv_id: int):
|
||||
think = bool(data.get("think", False))
|
||||
rag_project_id = data.get("rag_project_id") or None
|
||||
workspace_project_id = data.get("workspace_project_id") or None
|
||||
user_timezone = data.get("user_timezone") or None
|
||||
if not user_timezone:
|
||||
user_timezone = await get_setting(uid, "user_timezone") or None
|
||||
effective_rag_project_id = workspace_project_id or rag_project_id
|
||||
|
||||
# Reject if generation already running for this conversation
|
||||
@@ -178,6 +186,7 @@ async def send_message_route(conv_id: int):
|
||||
think=think,
|
||||
rag_project_id=effective_rag_project_id,
|
||||
workspace_project_id=workspace_project_id,
|
||||
user_timezone=user_timezone,
|
||||
))
|
||||
|
||||
return jsonify({
|
||||
@@ -401,21 +410,36 @@ async def chat_status_route():
|
||||
async def list_models_route():
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = [
|
||||
{"name": m["name"], "size": m.get("size", 0)}
|
||||
for m in data.get("models", [])
|
||||
]
|
||||
return jsonify({"models": models})
|
||||
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
|
||||
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
|
||||
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
|
||||
|
||||
loaded_names: set[str] = set()
|
||||
if not isinstance(ps_resp, Exception):
|
||||
try:
|
||||
ps_resp.raise_for_status()
|
||||
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
models = []
|
||||
if not isinstance(tags_resp, Exception):
|
||||
tags_resp.raise_for_status()
|
||||
for m in tags_resp.json().get("models", []):
|
||||
models.append({
|
||||
"name": m["name"],
|
||||
"size": m.get("size", 0),
|
||||
"modified_at": m.get("modified_at", ""),
|
||||
"loaded": m["name"] in loaded_names,
|
||||
})
|
||||
return jsonify({"models": models})
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list Ollama models: %s", e)
|
||||
return jsonify({"models": [], "error": str(e)}), 200
|
||||
|
||||
|
||||
@chat_bp.route("/models/pull", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
async def pull_model_route():
|
||||
"""Pull a model from Ollama, streaming progress via SSE."""
|
||||
data = await request.get_json()
|
||||
@@ -454,7 +478,7 @@ async def pull_model_route():
|
||||
|
||||
|
||||
@chat_bp.route("/models/delete", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
async def delete_model_route():
|
||||
"""Delete a model from Ollama."""
|
||||
data = await request.get_json()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Calendar events REST API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required
|
||||
import fabledassistant.services.events as events_svc
|
||||
|
||||
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
|
||||
|
||||
|
||||
def _get_current_user_id() -> int:
|
||||
return g.user.id
|
||||
|
||||
|
||||
@events_bp.get("")
|
||||
@login_required
|
||||
async def list_events():
|
||||
date_from_str = request.args.get("from")
|
||||
date_to_str = request.args.get("to")
|
||||
if not date_from_str or not date_to_str:
|
||||
return jsonify({"error": "from and to query params are required"}), 400
|
||||
try:
|
||||
date_from = datetime.fromisoformat(date_from_str)
|
||||
date_to = datetime.fromisoformat(date_to_str)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
events = await events_svc.list_events(
|
||||
user_id=_get_current_user_id(),
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
return jsonify([e.to_dict() for e in events])
|
||||
|
||||
|
||||
@events_bp.post("")
|
||||
@login_required
|
||||
async def create_event():
|
||||
data = await request.get_json() or {}
|
||||
if not data.get("title") or not data.get("start_dt"):
|
||||
return jsonify({"error": "title and start_dt are required"}), 400
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(data["start_dt"])
|
||||
end_dt = datetime.fromisoformat(data["end_dt"]) if data.get("end_dt") else None
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
event = await events_svc.create_event(
|
||||
user_id=_get_current_user_id(),
|
||||
title=data["title"],
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
all_day=data.get("all_day", False),
|
||||
description=data.get("description", ""),
|
||||
location=data.get("location", ""),
|
||||
color=data.get("color", ""),
|
||||
recurrence=data.get("recurrence"),
|
||||
project_id=data.get("project_id"),
|
||||
)
|
||||
return jsonify(event.to_dict()), 201
|
||||
|
||||
|
||||
@events_bp.get("/<int:event_id>")
|
||||
@login_required
|
||||
async def get_event(event_id: int):
|
||||
event = await events_svc.get_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
)
|
||||
if event is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return jsonify(event.to_dict())
|
||||
|
||||
|
||||
@events_bp.patch("/<int:event_id>")
|
||||
@login_required
|
||||
async def update_event(event_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields: dict = {}
|
||||
for str_field in ("title", "description", "location", "color", "recurrence"):
|
||||
if str_field in data:
|
||||
fields[str_field] = data[str_field]
|
||||
for bool_field in ("all_day",):
|
||||
if bool_field in data:
|
||||
fields[bool_field] = data[bool_field]
|
||||
for int_field in ("project_id",):
|
||||
if int_field in data:
|
||||
fields[int_field] = data[int_field]
|
||||
for dt_field in ("start_dt", "end_dt"):
|
||||
if dt_field in data and data[dt_field]:
|
||||
try:
|
||||
fields[dt_field] = datetime.fromisoformat(data[dt_field])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
||||
event = await events_svc.update_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
**fields,
|
||||
)
|
||||
if event is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return jsonify(event.to_dict())
|
||||
|
||||
|
||||
@events_bp.delete("/<int:event_id>")
|
||||
@login_required
|
||||
async def delete_event(event_id: int):
|
||||
event = await events_svc.get_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
)
|
||||
if event is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
await events_svc.delete_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
)
|
||||
return "", 204
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Serve the fable-mcp distribution wheel built into the Docker image."""
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, jsonify, send_file
|
||||
|
||||
from fabledassistant.auth import login_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
fable_mcp_dist_bp = Blueprint("fable_mcp_dist", __name__, url_prefix="/api/fable-mcp")
|
||||
|
||||
# Wheel is built into the image at this path (see Dockerfile)
|
||||
_DIST_DIR = Path(os.environ.get("FABLE_MCP_DIST_DIR", "/app/dist"))
|
||||
|
||||
|
||||
def _find_wheel() -> Path | None:
|
||||
"""Return the newest fable_mcp wheel in the dist dir, or None."""
|
||||
wheels = sorted(_DIST_DIR.glob("fable_mcp-*.whl"), reverse=True)
|
||||
return wheels[0] if wheels else None
|
||||
|
||||
|
||||
@fable_mcp_dist_bp.route("/info", methods=["GET"])
|
||||
@login_required
|
||||
async def fable_mcp_info():
|
||||
"""Return availability and filename of the bundled fable-mcp wheel."""
|
||||
wheel = _find_wheel()
|
||||
return jsonify({
|
||||
"available": wheel is not None,
|
||||
"filename": wheel.name if wheel else None,
|
||||
})
|
||||
|
||||
|
||||
@fable_mcp_dist_bp.route("/download", methods=["GET"])
|
||||
@login_required
|
||||
async def download_fable_mcp():
|
||||
"""Serve the fable-mcp wheel file as a download."""
|
||||
wheel = _find_wheel()
|
||||
if wheel is None:
|
||||
return jsonify({"error": "Package not built into this image"}), 404
|
||||
return await send_file(wheel, as_attachment=True)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user