Merge pull request 'v26.03.11.1' (#1) from dev into main

Reviewed-on: bvandeusen/FabledAssistant#1
This commit was merged in pull request #1.
This commit is contained in:
2026-03-11 23:46:08 -04:00
109 changed files with 15304 additions and 3275 deletions
-67
View File
@@ -1,67 +0,0 @@
# Branch push (dev): builds and pushes :dev + :<sha>
# Branch push (main): builds and pushes :latest + :<sha>
# Tag push (v1.2.0): builds and pushes :latest + :<sha> + :v1.2.0
#
# To cut a versioned release:
# git tag v1.2.0 && git push origin v1.2.0
#
# Required secrets (repo → Settings → Secrets → Actions):
# REGISTRY_USER — your Forgejo username
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
name: Build & push image
on:
push:
branches: [main, dev]
tags: ["v*"]
paths:
- "src/**"
- "frontend/**"
- "Dockerfile"
- "pyproject.toml"
- "alembic/**"
- "alembic.ini"
- ".forgejo/workflows/build.yml"
env:
REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
jobs:
build:
name: Build & push
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate image tags
id: tags
run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
TAGS="$TAGS,${{ env.IMAGE }}:latest"
elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
TAGS="$TAGS,${{ env.IMAGE }}:dev"
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
fi
echo "value=$TAGS" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Forgejo registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.tags.outputs.value }}
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
+92 -21
View File
@@ -1,14 +1,28 @@
# Runs on every push and pull request.
# Fast checks only — no Docker build. Second runs take ~30s due to caching.
name: CI
# CI runs first; build only proceeds if all checks pass.
#
# Push to dev: typecheck + lint + test → build :dev + :<sha>
# Push to main: typecheck + lint + test only (no build — wait for release tag)
# Tag v*: typecheck + lint + test → build :latest + :<sha> + :<version>
# Pull request: typecheck + lint + test only (no build)
#
# Required secrets (repo → Settings → Secrets → Actions):
# REGISTRY_USER — your Forgejo username
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
name: CI & Build
on:
push:
branches: [main, dev]
tags: ["v*"]
paths:
- "src/**"
- "frontend/**"
- "tests/**"
- "pyproject.toml"
- "alembic/**"
- "alembic.ini"
- "Dockerfile"
- "assets/**"
- ".forgejo/workflows/ci.yml"
pull_request:
paths:
@@ -18,16 +32,20 @@ on:
- "pyproject.toml"
- ".forgejo/workflows/ci.yml"
env:
REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
jobs:
typecheck:
name: TypeScript typecheck
runs-on: ubuntu-latest
runs-on: py3.12-node22
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
@@ -41,32 +59,85 @@ jobs:
lint:
name: Python lint
runs-on: ubuntu-latest
container:
image: python:3.12-slim
runs-on: py3.12-node22
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install ruff
run: pip install ruff
run: pip install --break-system-packages ruff
- name: Lint
run: ruff check src/
test:
name: Python tests
runs-on: ubuntu-latest
container:
image: python:3.12-slim
runs-on: py3.12-node22
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# git is required by setuptools-scm during package build
- name: Install git
run: apt-get update -qq && apt-get install -y -qq git
# Python 3.12 is pre-installed in the runner-base image (py3.12-node22).
- name: Cache pip wheels
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('pyproject.toml') }}
restore-keys: pip-
- name: Create virtual environment
run: python3.12 -m venv /opt/venv
- name: Install package with dev deps
run: pip install -e ".[dev]"
run: |
/opt/venv/bin/pip install --upgrade pip setuptools wheel
/opt/venv/bin/pip install --no-build-isolation http-ece
/opt/venv/bin/pip install -e ".[dev]"
- name: Run tests
run: pytest tests/ -v
run: /opt/venv/bin/python -m pytest tests/ -v
build:
name: Build & push image
needs: [typecheck, lint, test]
# Build on dev branch pushes and version tag pushes only.
# main branch pushes run CI for safety but do not build —
# the release tag (v*) is the sole trigger for a production image.
if: |
github.event_name == 'push' &&
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
runs-on: py3.12-node22
steps:
- uses: actions/checkout@v6
- name: Generate image tags and version
id: tags
run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
BUILD_VERSION="dev"
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
TAGS="$TAGS,${{ env.IMAGE }}:dev"
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}"
fi
echo "value=$TAGS" >> $GITHUB_OUTPUT
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Forgejo registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
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
+7
View File
@@ -14,9 +14,15 @@ frontend/dist/
# IDE
.vscode/
.vscodium/
.idea/
*.swp
*.swo
settings.local.json
# Claude Code
.claude/
docs/superpowers/
# Environment
.env
@@ -27,3 +33,4 @@ docker-compose.override.yml
# Misc
*.log
.DS_Store
.superpowers/
+6 -1
View File
@@ -1,5 +1,5 @@
# Stage 1: Build Vue frontend
FROM node:20-alpine AS build-frontend
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
@@ -21,5 +21,10 @@ COPY alembic/ alembic/
# Ensure Python finds the source tree (where static files live) before site-packages
ENV PYTHONPATH=/app/src
# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N
# Falls back to "dev" for local / untagged builds
ARG BUILD_VERSION=dev
ENV APP_VERSION=$BUILD_VERSION
EXPOSE 5000
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+116 -63
View File
@@ -1,31 +1,61 @@
# Fabled Assistant
A self-hosted note-taking and task-tracking application with integrated LLM capabilities. Write, organize, and enhance your notes with the help of a local AI assistant — all running on your own hardware.
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
**Notes with inline formatting** — Write in Markdown with a live-preview editor. Headings, bold, italic, lists, and code blocks render inline as you type, similar to Obsidian or Typora.
**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.
**Task tracking**Any note can become a task with a status (todo, in progress, done), priority level, and due date. Convert freely between notes and tasks without losing content.
**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.
**Wikilinks and backlinks** — Link notes together with `[[Title]]` syntax. Click a wikilink to navigate to (or auto-create) the referenced note. Each note shows what links to it.
**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.
**Tag organization** — Add `#tags` anywhere in your text. Tags are extracted automatically and support hierarchical filtering (e.g., `#project/webapp` matches both `project` and `project/webapp`). Tag autocomplete suggests existing tags as you type.
**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.
**AI writing assistant** — Select a section of your note and give the assistant an instruction ("summarize this", "make it more concise", "add examples"). The assistant streams a suggestion that you can accept or reject. Powered by Ollama running locally.
**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.
**AI chat** — Have conversations with your AI assistant. The chat is context-aware: it can automatically find and reference your notes to give more relevant answers. Attach specific notes for focused discussions. Save useful responses as new notes.
**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.
**Multi-user support** — Multiple users can share the same instance with isolated data. The first registered user becomes the admin.
**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.
**Dark and light themes** — Defaults to dark mode with a one-click toggle. All views respect your preference.
**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 resources to run an LLM (8GB+ RAM recommended for smaller models)
- A machine with enough RAM to run an LLM (8 GB+ recommended for smaller models like `llama3.2`)
### Quick Start
@@ -42,54 +72,69 @@ A self-hosted note-taking and task-tracking application with integrated LLM capa
3. Open `http://localhost:5000` in your browser.
4. Register the first user account (this account becomes the admin).
4. Register the first user account this account becomes the admin.
5. Go to **Settings** to download an LLM model. Smaller models like `llama3.2` (2GB) work well for getting started.
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 in the editor.
- **Link notes** by typing `[[` to get a wikilink autocomplete dropdown.
- **Tag your notes** by typing `#` followed by a tag name. Autocomplete suggests existing tags.
- **Use the AI assistant** in the bottom panel of the editor. Select a section, write an instruction, and click Generate.
- **Chat with the AI** from the Chat page. Attach notes for context or let the assistant find relevant notes automatically.
- **Convert notes to tasks** from the note viewer to add status, priority, and due dates.
- **Save chat responses** as notes to preserve useful AI-generated content.
- **Backup your data** from Settings (admin users can export and restore full backups).
- **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 done via environment variables. See `docker-compose.yml` for defaults.
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` | Default LLM model to auto-pull on startup |
| `SECURE_COOKIES` | `false` | Set to `true` when behind TLS to add `Secure` flag to session cookies |
| `LOG_LEVEL` | `INFO` | Logging level |
| `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 deployments, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits.
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. You must run it behind a reverse proxy for production use:
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 and forward traffic to port 5000
- Terminate TLS at the proxy; forward to port 5000
- **Do not expose port 5000 directly to the internet**
- **Rate-limit auth endpoints** at the proxy layer — recommended: limit `/api/auth/login` and `/api/auth/register` to ~5 requests/minute per IP to prevent brute-force attacks
- **Set Content Security Policy headers** at the proxy — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'`
- **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
- **Set a strong `SECRET_KEY`** — used to sign session cookies. Generate one 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 registers (who becomes admin), registration is closed by default. The admin can re-enable it from the user management page (`/admin/users`).
- **Use Docker secrets in production** — `docker-compose.prod.yml` reads `SECRET_KEY_FILE` and `DATABASE_URL_FILE` instead of plain environment variables.
- **Keep Ollama on an internal network** — The default compose files keep Ollama on a Docker-internal network, not exposed to the host.
- **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
@@ -98,61 +143,69 @@ Fabled Assistant does **not** handle SSL/TLS. You must run it behind a reverse p
| Layer | Technology |
|-------|------------|
| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router |
| Editor | Tiptap (ProseMirror) with custom extensions |
| Backend | Python 3.12, Quart (async Flask-like framework) |
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic migrations |
| LLM | Ollama (or any OpenAI-compatible API) |
| Deployment | Docker Compose (app + PostgreSQL + Ollama) |
| 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 application runs as a single container serving both the Vue.js SPA and the REST API. The frontend is built by Vite during the Docker image build and served as static files by Quart. API routes live under `/api/`.
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 use Server-Sent Events (SSE) for streaming responses. Chat generation runs in background `asyncio` tasks with an in-memory event buffer that supports client reconnection without data loss.
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.
The editor uses a Markdown-to-HTML-to-Markdown round-trip: content is stored as Markdown, converted to HTML for Tiptap editing, and serialized back to Markdown on every change. ProseMirror decoration plugins provide visual highlighting for tags and wikilinks without custom node types.
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.450.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
All data is stored in PostgreSQL. The schema uses a unified model where tasks are notes with additional attributes (`status`, `priority`, `due_date`). Migrations are idempotent raw SQL and run automatically on container startup.
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/ # Python backend
│ ├── app.py # Quart app factory
│ ├── models/ # SQLAlchemy models
│ ├── routes/ # API route blueprints
│ ├── services/ # Business logic
│ └── static/ # Built frontend (generated)
└── frontend/ # Vue.js frontend
├── 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 components
├── components/ # Reusable UI components
├── extensions/ # Tiptap/ProseMirror plugins
├── composables/ # Vue composables
── stores/ # Pinia state management
└── api/ # API client
├── 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:
All development is done via Docker. No local dependency installation required.
```bash
# Start the dev stack
# Start the dev stack (hot-reload not included — rebuild on changes)
docker compose up --build
# Reset database (destroy volumes and rebuild)
# Reset the database
docker compose down -v && docker compose up --build
# Lint, format, typecheck, test (runs inside Docker via Makefile)
make check
```
No local dependency installation required.
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`.
---
## License
@@ -0,0 +1,209 @@
"""Add groups, group_memberships, project_shares, note_shares, notifications tables.
Revision ID: 0025
Revises: 0024
Create Date: 2026-03-11
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0025"
down_revision = "0024"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"groups",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.Text(), nullable=False, unique=True),
sa.Column("description", sa.Text()),
sa.Column(
"created_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_table(
"group_memberships",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("role", sa.Text(), nullable=False, server_default="member"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("group_id", "user_id", name="uq_gm_group_user"),
)
op.create_index("idx_gm_user", "group_memberships", ["user_id"])
op.create_table(
"project_shares",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"project_id",
sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"shared_with_user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
),
sa.Column(
"shared_with_group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
),
sa.Column("permission", sa.Text(), nullable=False),
sa.Column(
"invited_by",
sa.Integer(),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.CheckConstraint(
"(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)",
name="ck_ps_exclusive_target",
),
)
op.create_index("idx_ps_project", "project_shares", ["project_id"])
op.execute(
"CREATE UNIQUE INDEX idx_ps_user ON project_shares(project_id, shared_with_user_id)"
" WHERE shared_with_user_id IS NOT NULL"
)
op.execute(
"CREATE UNIQUE INDEX idx_ps_group ON project_shares(project_id, shared_with_group_id)"
" WHERE shared_with_group_id IS NOT NULL"
)
op.create_table(
"note_shares",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"note_id",
sa.Integer(),
sa.ForeignKey("notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"shared_with_user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
),
sa.Column(
"shared_with_group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
),
sa.Column("permission", sa.Text(), nullable=False),
sa.Column(
"invited_by",
sa.Integer(),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.CheckConstraint(
"(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)",
name="ck_ns_exclusive_target",
),
)
op.execute(
"CREATE UNIQUE INDEX idx_ns_user ON note_shares(note_id, shared_with_user_id)"
" WHERE shared_with_user_id IS NOT NULL"
)
op.execute(
"CREATE UNIQUE INDEX idx_ns_group ON note_shares(note_id, shared_with_group_id)"
" WHERE shared_with_group_id IS NOT NULL"
)
op.create_table(
"notifications",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("type", sa.Text(), nullable=False),
sa.Column(
"payload",
postgresql.JSONB(),
nullable=False,
server_default="{}",
),
sa.Column("read_at", sa.DateTime(timezone=True)),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.execute(
"CREATE INDEX idx_notif_user_unread ON notifications(user_id, read_at)"
" WHERE read_at IS NULL"
)
def downgrade() -> None:
op.drop_table("notifications")
op.drop_table("note_shares")
op.drop_table("project_shares")
op.drop_table("group_memberships")
op.drop_table("groups")
@@ -0,0 +1,75 @@
"""Add briefing tables: rss_feeds, rss_items, weather_cache, conversation briefing columns."""
from alembic import op
revision = "0026"
down_revision = "0025"
def upgrade() -> None:
# Extend conversations table
op.execute("""
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS conversation_type TEXT NOT NULL DEFAULT 'chat',
ADD COLUMN IF NOT EXISTS briefing_date DATE
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_conversations_type ON conversations(conversation_type)")
# RSS feeds
op.execute("""
CREATE TABLE IF NOT EXISTS rss_feeds (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
category TEXT,
last_fetched_at TIMESTAMPTZ,
CONSTRAINT uq_rss_feeds_user_url UNIQUE (user_id, url)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_rss_feeds_user_id ON rss_feeds(user_id)")
# RSS items
op.execute("""
CREATE TABLE IF NOT EXISTS rss_items (
id SERIAL PRIMARY KEY,
feed_id INTEGER NOT NULL REFERENCES rss_feeds(id) ON DELETE CASCADE,
guid TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
published_at TIMESTAMPTZ,
content TEXT NOT NULL DEFAULT '',
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_rss_items_feed_guid UNIQUE (feed_id, guid)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_rss_items_feed_id ON rss_items(feed_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_rss_items_published_at ON rss_items(published_at)")
# Weather cache
op.execute("""
CREATE TABLE IF NOT EXISTS weather_cache (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
location_key TEXT NOT NULL,
location_label TEXT NOT NULL DEFAULT '',
forecast_json JSONB,
previous_json JSONB,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_weather_cache_user_location UNIQUE (user_id, location_key)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_weather_cache_user_id ON weather_cache(user_id)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_weather_cache_user_id")
op.execute("DROP TABLE IF EXISTS weather_cache")
op.execute("DROP INDEX IF EXISTS ix_rss_items_published_at")
op.execute("DROP INDEX IF EXISTS ix_rss_items_feed_id")
op.execute("DROP TABLE IF EXISTS rss_items")
op.execute("DROP INDEX IF EXISTS ix_rss_feeds_user_id")
op.execute("DROP TABLE IF EXISTS rss_feeds")
op.execute("DROP INDEX IF EXISTS ix_conversations_type")
op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS briefing_date")
op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS conversation_type")
+538
View File
@@ -0,0 +1,538 @@
# 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
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+985 -561
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -9,27 +9,27 @@
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-link": "^2.11.0",
"@tiptap/extension-placeholder": "^2.11.0",
"@tiptap/extension-task-item": "^2.27.2",
"@tiptap/extension-task-list": "^2.27.2",
"@tiptap/pm": "^2.11.0",
"@tiptap/starter-kit": "^2.11.0",
"@tiptap/suggestion": "^2.11.0",
"@tiptap/vue-3": "^2.11.0",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list": "^3.0.0",
"@tiptap/extensions": "^3.0.0",
"@tiptap/pm": "^3.0.0",
"@tiptap/starter-kit": "^3.0.0",
"@tiptap/suggestion": "^3.0.0",
"@tiptap/vue-3": "^3.0.0",
"d3": "^7",
"dompurify": "^3.1.0",
"marked": "^15.0.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
"marked": "^17.0.0",
"pinia": "^3.0.0",
"vue": "3.5.30",
"vue-router": "^5.0.0"
},
"devDependencies": {
"@types/d3": "^7",
"@types/dompurify": "^3.0.0",
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "~5.6.0",
"vite": "^6.0.0",
"vue-tsc": "^2.1.0"
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "~5.9.0",
"vite": "^7.0.0",
"vue-tsc": "^3.0.0"
}
}
+15 -5
View File
@@ -2,12 +2,22 @@
self.addEventListener('push', event => {
if (!event.data) return;
const data = event.data.json();
const notifUrl = data.url || '/';
event.waitUntil(
self.registration.showNotification(data.title || 'Fabled Assistant', {
body: data.body || '',
icon: '/favicon.ico',
badge: '/favicon.ico',
data: { url: data.url || '/' },
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
// Suppress notification if the user already has the relevant page focused
for (const client of clientList) {
if (client.visibilityState === 'visible' && client.url.includes(notifUrl)) {
return;
}
}
return self.registration.showNotification(data.title || 'Fabled Assistant', {
body: data.body || '',
icon: '/favicon.ico',
badge: '/favicon.ico',
data: { url: notifUrl },
});
})
);
});
+22 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from "vue";
import { onMounted, onUnmounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import AppHeader from "@/components/AppHeader.vue";
import ToastNotification from "@/components/ToastNotification.vue";
@@ -8,10 +8,12 @@ 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";
useTheme();
const router = useRouter();
const appVersion = ref("dev");
const authStore = useAuthStore();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
@@ -123,6 +125,12 @@ onMounted(async () => {
if (authStore.isAuthenticated) {
startAppServices();
}
try {
const data = await apiGet<{ version: string }>("/api/version");
appVersion.value = data.version;
} catch {
// silent — version display is non-critical
}
});
watch(
@@ -150,6 +158,7 @@ onUnmounted(() => {
<div id="main-content" class="app-content">
<router-view />
</div>
<footer class="app-footer">v{{ appVersion }}</footer>
</div>
<!-- Keyboard shortcuts overlay -->
@@ -299,6 +308,18 @@ onUnmounted(() => {
overflow: hidden;
}
/* Version footer */
.app-footer {
flex-shrink: 0;
text-align: center;
padding: 0.2rem 0;
font-size: 0.68rem;
color: var(--color-text-muted);
opacity: 0.45;
user-select: none;
letter-spacing: 0.03em;
}
/* Shortcuts overlay */
.shortcuts-overlay {
position: fixed;
+221
View File
@@ -75,6 +75,111 @@ export async function apiDelete(path: string): Promise<void> {
return handleResponse<void>(res, path);
}
// ---------------------------------------------------------------------------
// Sharing, Groups, Notifications, User search
// ---------------------------------------------------------------------------
export interface ShareEntry {
id: number
permission: string
shared_with_user_id: number | null
shared_with_group_id: number | null
username?: string
group_name?: string
invited_by: number
created_at: string
}
export interface GroupEntry {
id: number
name: string
description: string | null
created_by: number | null
member_count: number
is_member: boolean
created_at: string
updated_at: string
}
export interface GroupMember {
id: number
group_id: number
user_id: number
role: string
username: string
email: string | null
created_at: string
}
export interface NotificationEntry {
id: number
user_id: number
type: string
payload: Record<string, unknown>
read_at: string | null
created_at: string
}
export interface UserSearchResult {
id: number
username: string
email: string | null
}
// --- User search ---
export const searchUsers = (q: string) =>
apiGet<{ users: UserSearchResult[] }>(`/api/users/search?q=${encodeURIComponent(q)}`).then(r => r.users)
// --- Groups ---
export const listGroups = () => apiGet<{ groups: GroupEntry[] }>('/api/groups').then(r => r.groups)
export const createGroup = (name: string, description?: string) =>
apiPost<GroupEntry>('/api/groups', { name, description })
export const updateGroup = (id: number, data: { name?: string; description?: string }) =>
apiPatch<GroupEntry>(`/api/groups/${id}`, data)
export const deleteGroup = (id: number) => apiDelete(`/api/groups/${id}`)
export const getGroupDetail = (id: number) =>
apiGet<GroupEntry & { members: GroupMember[] }>(`/api/groups/${id}`)
export const listGroupMembers = (id: number) =>
apiGet<{ members: GroupMember[] }>(`/api/groups/${id}/members`).then(r => r.members)
export const addGroupMember = (groupId: number, userId: number, role: string) =>
apiPost<GroupMember>(`/api/groups/${groupId}/members`, { user_id: userId, role })
export const updateGroupMember = (groupId: number, userId: number, role: string) =>
apiPatch<GroupMember>(`/api/groups/${groupId}/members/${userId}`, { role })
export const removeGroupMember = (groupId: number, userId: number) =>
apiDelete(`/api/groups/${groupId}/members/${userId}`)
// --- Project shares ---
export const listProjectShares = (projectId: number) =>
apiGet<{ shares: ShareEntry[] }>(`/api/projects/${projectId}/shares`).then(r => r.shares)
export const createProjectShare = (projectId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
apiPost<ShareEntry>(`/api/projects/${projectId}/shares`, body)
export const updateProjectShare = (projectId: number, shareId: number, permission: string) =>
apiPatch<ShareEntry>(`/api/projects/${projectId}/shares/${shareId}`, { permission })
export const deleteProjectShare = (projectId: number, shareId: number) =>
apiDelete(`/api/projects/${projectId}/shares/${shareId}`)
// --- Note / task shares ---
export const listNoteShares = (noteId: number) =>
apiGet<{ shares: ShareEntry[] }>(`/api/notes/${noteId}/shares`).then(r => r.shares)
export const createNoteShare = (noteId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
apiPost<ShareEntry>(`/api/notes/${noteId}/shares`, body)
export const updateNoteShare = (noteId: number, shareId: number, permission: string) =>
apiPatch<ShareEntry>(`/api/notes/${noteId}/shares/${shareId}`, { permission })
export const deleteNoteShare = (noteId: number, shareId: number) =>
apiDelete(`/api/notes/${noteId}/shares/${shareId}`)
// --- Shared-with-me ---
export const getSharedWithMe = () =>
apiGet<{ projects: Record<string, unknown>[]; notes: Record<string, unknown>[] }>('/api/shared-with-me')
// --- In-app notifications ---
export const getNotifications = (all = false) =>
apiGet<{ notifications: NotificationEntry[] }>(`/api/notifications${all ? '?all=true' : ''}`).then(r => r.notifications)
export const getNotificationCount = () =>
apiGet<{ count: number }>('/api/notifications/count').then(r => r.count)
export const markNotificationRead = (id: number) => apiPost<void>(`/api/notifications/${id}/read`, {})
export const markAllNotificationsRead = () => apiPost<{ marked: number }>('/api/notifications/read-all', {})
export interface SSEStreamHandle {
close(): void;
/** Resolves when the stream closes (normally or via error/abort). */
@@ -195,6 +300,122 @@ export function apiSSEStream(
return { close: () => controller.abort(), done };
}
// ---------------------------------------------------------------------------
// Briefing
// ---------------------------------------------------------------------------
export interface BriefingLocation {
label: string;
address: string;
lat?: number;
lon?: number;
}
export interface BriefingSlots {
compilation: boolean;
morning: boolean;
midday: boolean;
afternoon: boolean;
}
export interface BriefingConfig {
enabled: boolean;
locations: {
home?: BriefingLocation;
work?: BriefingLocation;
};
use_caldav_event_locations: boolean;
work_days: number[];
slots: BriefingSlots;
notifications: boolean;
}
export interface BriefingFeed {
id: number;
title: string;
url: string;
category: string | null;
last_fetched_at: string | null;
}
export interface BriefingConversation {
id: number;
title: string;
briefing_date: string | null;
message_count: number;
created_at: string;
}
export interface BriefingMessage {
id: number;
role: 'user' | 'assistant' | 'system';
content: string;
created_at: string;
}
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
enabled: false,
locations: {},
use_caldav_event_locations: false,
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
};
export async function getBriefingConfig(): Promise<BriefingConfig> {
try {
const data = await apiGet<BriefingConfig>('/api/briefing/config');
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
} catch {
return { ...DEFAULT_BRIEFING_CONFIG };
}
}
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
await apiPut('/api/briefing/config', config);
}
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
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 });
return { ...data, last_fetched_at: null };
}
export async function deleteBriefingFeed(id: number): Promise<void> {
await apiDelete(`/api/briefing/feeds/${id}`);
}
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
return data.conversations;
}
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
return apiGet('/api/briefing/conversations/today');
}
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
return data.messages;
}
export async function triggerBriefingSlot(slot: string): Promise<void> {
await apiPost('/api/briefing/trigger', { slot });
}
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 });
return { lat: r.lat, lon: r.lon, display_name: r.label };
} catch {
return null;
}
}
export async function apiStreamPost(
path: string,
body: unknown,
+30 -9
View File
@@ -51,15 +51,23 @@
color: var(--color-primary);
}
.btn-save {
padding: 0.45rem 1rem;
background: var(--color-primary);
padding: 0.45rem 1.1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
font-size: 0.875rem;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-save:hover:not(:disabled) {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
opacity: 0.95;
}
.btn-save:disabled {
opacity: 0.6;
opacity: 0.55;
cursor: default;
}
.btn-delete {
@@ -86,13 +94,26 @@
color: var(--color-primary);
}
.title-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
font-size: 1.25rem;
font-weight: 600;
background: var(--color-bg-card);
padding: 0.4rem 0;
border: none;
border-bottom: 1.5px solid var(--color-border);
border-radius: 0;
font-size: 1.5rem;
font-weight: 700;
font-family: "Fraunces", Georgia, serif;
background: transparent;
color: var(--color-text);
width: 100%;
transition: border-color 0.15s;
}
.title-input:focus {
outline: none;
border-bottom-color: var(--color-primary);
}
.title-input::placeholder {
color: var(--color-text-muted);
font-style: italic;
font-weight: 400;
}
.editor-tabs {
display: flex;
+64 -26
View File
@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&display=swap');
:root {
--color-bg: #f5f5fb;
--color-bg-secondary: #ededf5;
@@ -38,33 +40,37 @@
--color-input-bar-text: #1a1a1a;
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4);
--color-overlay: rgba(0, 0, 0, 0.45);
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
--color-bubble-user-text: #3a3a4a;
--color-bubble-asst-shadow: 0 2px 16px rgba(99, 102, 241, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
}
[data-theme="dark"] {
--color-bg: #1a1a2e;
--color-bg-secondary: #16213e;
--color-bg-card: #1f2940;
--color-text: #e0e0e0;
--color-text-secondary: #a0a0b0;
--color-text-muted: #707080;
--color-border: #2a3a5c;
--color-input-border: #3a4a6c;
--color-primary: #5b9cf6;
--color-bg: #111113;
--color-bg-secondary: #18181f;
--color-bg-card: #1e1e27;
--color-text: #e4e4f0;
--color-text-secondary: #8888a8;
--color-text-muted: #52526a;
--color-border: rgba(99, 102, 241, 0.10);
--color-input-border: rgba(99, 102, 241, 0.22);
--color-primary: #818cf8;
--color-danger: #f44336;
--color-tag-bg: #1e3a5f;
--color-tag-text: #7bb8f6;
--color-shadow: rgba(0, 0, 0, 0.3);
--color-tag-bg: #2a2a45;
--color-tag-text: #a5b4fc;
--color-shadow: rgba(0, 0, 0, 0.4);
--color-toast-success: #4caf50;
--color-toast-error: #f44336;
--color-status-todo: #9aa0a6;
--color-status-todo-bg: #2d333b;
--color-status-in-progress: #5b9cf6;
--color-status-in-progress-bg: #1e3a5f;
--color-status-todo-bg: #2a2a35;
--color-status-in-progress: #818cf8;
--color-status-in-progress-bg: #2a2a45;
--color-status-done: #4caf50;
--color-status-done-bg: #1b3a20;
--color-priority-low: #80cbc4;
@@ -73,18 +79,22 @@
--color-priority-medium-bg: #3a3520;
--color-priority-high: #f44336;
--color-priority-high-bg: #3a1a1a;
--color-wikilink: #ce93d8;
--color-wikilink-bg: #2a1a30;
--color-wikilink: #c4b5fd;
--color-wikilink-bg: #2a1a45;
--color-overdue: #f44336;
--color-code-bg: #161b22;
--color-code-inline-bg: #2a3040;
--color-table-stripe: #1a2030;
--color-code-bg: #16161d;
--color-code-inline-bg: #1e1e2d;
--color-table-stripe: #16161e;
--color-success: #4ade80;
--color-warning: #facc15;
--color-input-bar-bg: #1c1c1e;
--color-input-bar-text: #ffffff;
--color-input-bar-placeholder: rgba(255, 255, 255, 0.4);
--color-overlay: rgba(0, 0, 0, 0.6);
--color-input-bar-bg: #1e1e27;
--color-input-bar-text: #e4e4f0;
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
--color-overlay: rgba(0, 0, 0, 0.65);
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
--color-bubble-user-text: #b0b0c8;
--color-bubble-asst-shadow: 0 4px 28px rgba(99, 102, 241, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
}
*,
@@ -103,6 +113,11 @@ body {
transition: background-color 0.2s, color 0.2s;
}
h1, h2, h3 {
font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto;
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
@@ -113,6 +128,13 @@ a:focus-visible {
border-radius: var(--radius-sm);
}
button:not(:disabled):active,
.btn:not(:disabled):active,
[role="button"]:not(:disabled):active {
transform: scale(0.97);
transition: transform 0.08s ease;
}
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
@media (max-width: 768px) {
.hide-mobile {
@@ -131,6 +153,22 @@ a:focus-visible {
}
}
/* Thin indigo-tinted scrollbars */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.25);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.45);
}
/* Floating inline assist button (teleported to body, cannot be scoped) */
.inline-assist-btn {
position: fixed;
+31 -77
View File
@@ -1,21 +1,23 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { ref, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
const authStore = useAuthStore();
const chatStore = useChatStore();
const router = useRouter();
const route = useRoute();
const isChatActive = computed(() => route.path.startsWith("/chat"));
const mobileMenuOpen = ref(false);
const gearOpen = ref(false);
const gearMenuRef = ref<HTMLElement | null>(null);
const statusShortLabel = computed(() => {
if (chatStore.ollamaStatus === "unavailable") return "Offline";
@@ -48,16 +50,6 @@ function toggleMobileMenu() {
mobileMenuOpen.value = !mobileMenuOpen.value;
}
function toggleGear() {
gearOpen.value = !gearOpen.value;
}
function handleClickOutside(e: MouseEvent) {
if (gearMenuRef.value && !gearMenuRef.value.contains(e.target as Node)) {
gearOpen.value = false;
}
}
async function handleLogout() {
await authStore.logout();
mobileMenuOpen.value = false;
@@ -66,11 +58,7 @@ async function handleLogout() {
router.afterEach(() => {
mobileMenuOpen.value = false;
gearOpen.value = false;
});
onMounted(() => document.addEventListener("click", handleClickOutside));
onUnmounted(() => document.removeEventListener("click", handleClickOutside));
</script>
<template>
@@ -78,7 +66,7 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
<nav class="nav">
<!-- Left: brand -->
<router-link to="/" class="nav-brand">
<AppLogo />
<AppLogo :size="34" />
Fabled Assistant
</router-link>
@@ -87,8 +75,10 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" class="nav-link">Chat</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="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
</div>
<!-- Right: status + utilities + gear + user -->
@@ -98,26 +88,21 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
<span class="status-text">{{ statusShortLabel }}</span>
</span>
<NotificationBell />
<button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button>
<button class="btn-icon" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }}
</button>
<!-- Gear dropdown -->
<div class="gear-menu" ref="gearMenuRef">
<button class="btn-icon btn-gear" @click.stop="toggleGear" :class="{ active: gearOpen }" title="Settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<div v-if="gearOpen" class="gear-dropdown">
<router-link to="/settings" class="gear-item">Settings</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/users" class="gear-item">Users</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/logs" class="gear-item">Logs</router-link>
</div>
</div>
<!-- Settings link -->
<router-link to="/settings" class="btn-icon" aria-label="Settings" title="Settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</router-link>
<div class="user-info">
<span class="username">{{ authStore.user?.username }}</span>
@@ -139,12 +124,12 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" class="nav-link">Chat</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="/briefing" class="nav-link">Briefing</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>
<router-link v-if="authStore.isAdmin" to="/admin/users" class="nav-link">Users</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/logs" class="nav-link">Logs</router-link>
<div class="mobile-divider"></div>
<div class="mobile-actions">
<span class="status-indicator" :class="statusClass" :title="statusLabel">
@@ -166,11 +151,10 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
<style scoped>
.app-header {
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
position: relative;
}
.nav {
padding: 0.5rem 1.25rem;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
@@ -182,8 +166,11 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 700;
font-size: 1.1rem;
font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto;
font-weight: 600;
font-size: 1.15rem;
letter-spacing: -0.01em;
color: var(--color-primary);
text-decoration: none;
flex-shrink: 0;
@@ -217,12 +204,13 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
}
.nav-link:hover {
color: var(--color-primary);
background: var(--color-bg-card);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.nav-link.router-link-active {
color: var(--color-primary);
background: var(--color-bg-card);
font-weight: 600;
box-shadow: inset 0 -2px 0 var(--color-primary);
border-radius: 0;
}
/* Status indicator */
@@ -275,40 +263,6 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
border-color: var(--color-primary);
}
/* Gear dropdown */
.gear-menu {
position: relative;
}
.gear-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow, rgba(0,0,0,0.15));
min-width: 140px;
z-index: 200;
overflow: hidden;
display: flex;
flex-direction: column;
}
.gear-item {
padding: 0.55rem 0.9rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-decoration: none;
transition: background 0.12s, color 0.12s;
}
.gear-item:hover {
background: var(--color-bg-secondary);
color: var(--color-primary);
}
.gear-item.router-link-active {
color: var(--color-primary);
font-weight: 600;
}
/* User info */
.user-info {
display: flex;
+2 -2
View File
@@ -37,8 +37,8 @@ defineProps<{ size?: number }>();
<style scoped>
.logo-book {
fill: var(--color-text);
stroke: var(--color-text-secondary);
fill: var(--color-primary);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
.logo-spine {
stroke: var(--color-text-secondary);
@@ -0,0 +1,412 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import {
saveBriefingConfig,
geocodeAddress,
createBriefingFeed,
type BriefingConfig,
} from '@/api/client'
const emit = defineEmits<{ done: [] }>()
const step = ref(1)
const TOTAL_STEPS = 4
const config = reactive<BriefingConfig>({
enabled: true,
locations: {},
use_caldav_event_locations: false,
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
})
// Step 2 — locations
const homeAddress = ref('')
const workAddress = ref('')
const geocodingHome = ref(false)
const geocodingWork = ref(false)
const homeConfirmed = ref<string | null>(null)
const workConfirmed = ref<string | null>(null)
const homeError = ref('')
const workError = ref('')
async function lookupHome() {
if (!homeAddress.value.trim()) return
geocodingHome.value = true
homeError.value = ''
try {
const r = await geocodeAddress(homeAddress.value.trim())
if (r) {
config.locations.home = { label: 'Home', address: homeAddress.value.trim(), lat: r.lat, lon: r.lon }
homeConfirmed.value = r.display_name
} else {
homeError.value = 'Location not found'
}
} finally {
geocodingHome.value = false
}
}
async function lookupWork() {
if (!workAddress.value.trim()) return
geocodingWork.value = true
workError.value = ''
try {
const r = await geocodeAddress(workAddress.value.trim())
if (r) {
config.locations.work = { label: 'Work', address: workAddress.value.trim(), lat: r.lat, lon: r.lon }
workConfirmed.value = r.display_name
} else {
workError.value = 'Location not found'
}
} finally {
geocodingWork.value = false
}
}
// Step 3 — work days
function toggleDay(d: number) {
const idx = config.work_days.indexOf(d)
if (idx === -1) config.work_days.push(d)
else config.work_days.splice(idx, 1)
config.work_days.sort()
}
// Step 4 — RSS feeds
const newFeedUrl = ref('')
const pendingFeeds = ref<Array<{ url: string }>>([])
function addPendingFeed() {
if (!newFeedUrl.value.trim()) return
pendingFeeds.value.push({ url: newFeedUrl.value.trim() })
newFeedUrl.value = ''
}
function removePendingFeed(i: number) { pendingFeeds.value.splice(i, 1) }
// Finish
const finishing = ref(false)
async function finish() {
finishing.value = true
try {
await saveBriefingConfig(config)
for (const f of pendingFeeds.value) {
try { await createBriefingFeed(f.url) } catch { /* best-effort */ }
}
emit('done')
} finally {
finishing.value = false
}
}
</script>
<template>
<Teleport to="body">
<div class="wizard-overlay">
<div class="wizard-card" role="dialog" aria-label="Briefing Setup">
<!-- Progress bar -->
<div class="wizard-progress">
<div class="wizard-progress-fill" :style="{ width: `${(step / TOTAL_STEPS) * 100}%` }"></div>
</div>
<div class="wizard-step-label">Step {{ step }} of {{ TOTAL_STEPS }}</div>
<!-- Step 1: Welcome -->
<div v-if="step === 1" class="wizard-body">
<h2 class="wizard-title">Good morning.</h2>
<p class="wizard-text">
The Briefing is a daily conversation that summarises your day tasks, calendar,
weather, and news and checks in a few times throughout the day.
</p>
<p class="wizard-text">
It learns from your responses over time and adapts to your schedule.
Let's take a minute to set it up.
</p>
<div class="wizard-actions">
<button class="btn-wizard-primary" @click="step = 2">Get started</button>
</div>
</div>
<!-- Step 2: Locations -->
<div v-else-if="step === 2" class="wizard-body">
<h2 class="wizard-title">Where are you?</h2>
<p class="wizard-text">
Enter your home and work addresses. The briefing uses these to fetch weather
for the right places. You can skip either one.
</p>
<div class="wizard-field">
<label class="wizard-label">Home address</label>
<div class="wizard-input-row">
<input v-model="homeAddress" class="wizard-input" placeholder="e.g. 123 Main St, Springfield" @keydown.enter="lookupHome" />
<button class="btn-wizard-secondary" @click="lookupHome" :disabled="geocodingHome">
{{ geocodingHome ? '' : 'Look up' }}
</button>
</div>
<div v-if="homeConfirmed" class="wizard-confirmed">✓ {{ homeConfirmed }}</div>
<div v-if="homeError" class="wizard-error">{{ homeError }}</div>
</div>
<div class="wizard-field">
<label class="wizard-label">Work address</label>
<div class="wizard-input-row">
<input v-model="workAddress" class="wizard-input" placeholder="e.g. 456 Office Ave, Portland" @keydown.enter="lookupWork" />
<button class="btn-wizard-secondary" @click="lookupWork" :disabled="geocodingWork">
{{ geocodingWork ? '' : 'Look up' }}
</button>
</div>
<div v-if="workConfirmed" class="wizard-confirmed">✓ {{ workConfirmed }}</div>
<div v-if="workError" class="wizard-error">{{ workError }}</div>
</div>
<div class="wizard-actions">
<button class="btn-wizard-ghost" @click="step = 1">Back</button>
<button class="btn-wizard-primary" @click="step = 3">Continue</button>
</div>
</div>
<!-- Step 3: Work schedule -->
<div v-else-if="step === 3" class="wizard-body">
<h2 class="wizard-title">When do you go to the office?</h2>
<p class="wizard-text">
The 8am slot is labelled "you're at the office" on days you have marked as office days.
Toggle any days you typically commute.
</p>
<div class="wizard-days">
<button
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
:key="idx"
:class="['wizard-day-btn', { active: config.work_days.includes(idx) }]"
@click="toggleDay(idx)"
type="button"
>{{ day }}</button>
</div>
<div class="wizard-actions">
<button class="btn-wizard-ghost" @click="step = 2">Back</button>
<button class="btn-wizard-primary" @click="step = 4">Continue</button>
</div>
</div>
<!-- Step 4: RSS feeds -->
<div v-else-if="step === 4" class="wizard-body">
<h2 class="wizard-title">What do you follow?</h2>
<p class="wizard-text">
Add RSS or Atom feeds and the briefing will summarise recent items each morning.
You can add or remove feeds any time in Settings.
</p>
<div v-if="pendingFeeds.length" class="wizard-feeds-list">
<div v-for="(f, i) in pendingFeeds" :key="i" class="wizard-feed-row">
<span class="wizard-feed-url">{{ f.url }}</span>
<button class="btn-wizard-remove" @click="removePendingFeed(i)">✕</button>
</div>
</div>
<div class="wizard-add-feed">
<input v-model="newFeedUrl" class="wizard-input" placeholder="https://…/feed.xml" style="flex: 1" />
<button class="btn-wizard-secondary" @click="addPendingFeed" :disabled="!newFeedUrl.trim()">Add</button>
</div>
<div class="wizard-actions" style="margin-top: 1.5rem">
<button class="btn-wizard-ghost" @click="step = 3">Back</button>
<button class="btn-wizard-ghost" @click="finish" :disabled="finishing">Skip</button>
<button class="btn-wizard-primary" @click="finish" :disabled="finishing">
{{ finishing ? 'Setting up…' : 'Enable Briefing' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.wizard-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.wizard-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, 18px);
width: 520px;
max-width: 94vw;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.wizard-progress {
height: 3px;
background: var(--color-border);
}
.wizard-progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #4f46e5);
transition: width 0.3s ease;
}
.wizard-step-label {
font-size: 0.72rem;
color: var(--color-text-muted);
text-align: right;
padding: 0.5rem 1.5rem 0;
}
.wizard-body {
padding: 1.5rem 2rem 2rem;
}
.wizard-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.4rem;
font-weight: 700;
margin: 0 0 0.75rem;
color: var(--color-text);
}
.wizard-text {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.6;
margin: 0 0 0.75rem;
}
.wizard-field {
margin-bottom: 1rem;
}
.wizard-label {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text-muted);
display: block;
margin-bottom: 0.3rem;
}
.wizard-input-row {
display: flex;
gap: 0.5rem;
}
.wizard-input {
flex: 1;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
}
.wizard-input:focus { border-color: var(--color-primary); }
.wizard-confirmed {
font-size: 0.78rem;
color: var(--color-success, #22c55e);
margin-top: 0.25rem;
}
.wizard-error {
font-size: 0.78rem;
color: var(--color-danger, #ef4444);
margin-top: 0.25rem;
}
.wizard-days {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin: 1rem 0 1.5rem;
}
.wizard-day-btn {
padding: 0.4rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.wizard-day-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.wizard-feeds-list {
margin-bottom: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.wizard-feed-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
background: var(--color-bg-secondary);
border-radius: 6px;
}
.wizard-feed-url {
flex: 1;
font-size: 0.82rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-wizard-remove {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
border-radius: 4px;
}
.btn-wizard-remove:hover { color: var(--color-danger, #ef4444); }
.wizard-add-feed {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.wizard-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.btn-wizard-primary {
padding: 0.5rem 1.25rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
font-family: inherit;
}
.btn-wizard-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-wizard-secondary {
padding: 0.5rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
font-family: inherit;
}
.btn-wizard-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-wizard-ghost {
padding: 0.5rem 1rem;
border: none;
background: none;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
border-radius: 6px;
font-family: inherit;
}
.btn-wizard-ghost:hover { background: var(--color-bg-secondary); }
.btn-wizard-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
+20 -25
View File
@@ -118,17 +118,22 @@ const timingParts = computed((): string[] => {
.message-bubble {
padding: 0.75rem 1rem;
border-radius: 16px;
border-radius: 18px;
}
/* User prompts: recessed, muted — margin notes */
.role-user .message-bubble {
background: var(--color-primary);
color: #fff;
background: var(--color-bubble-user-bg);
border: 1px solid var(--color-bubble-user-border);
color: var(--color-bubble-user-text);
border-bottom-right-radius: 4px;
}
/* Assistant responses: elevated, lit — the primary text */
.role-assistant .message-bubble {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-left: 2px solid var(--color-primary);
box-shadow: var(--color-bubble-asst-shadow);
border-bottom-left-radius: 4px;
}
@@ -145,16 +150,23 @@ const timingParts = computed((): string[] => {
letter-spacing: 0.03em;
}
.role-user .role-label {
color: rgba(255, 255, 255, 0.7);
color: var(--color-text-muted);
opacity: 0.6;
}
.role-assistant .role-label {
color: var(--color-text-muted);
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto;
font-style: italic;
font-size: 0.8rem;
text-transform: none;
letter-spacing: 0;
}
.thinking-block {
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border-left: 2px solid rgba(129, 140, 248, 0.35);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
overflow: hidden;
}
.thinking-summary {
@@ -192,7 +204,6 @@ details[open] .thinking-summary::before {
max-height: 300px;
overflow-y: auto;
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
}
.message-content {
font-size: 0.95rem;
@@ -203,22 +214,6 @@ details[open] .thinking-summary::before {
margin-bottom: 0;
}
/* User bubble content overrides for readability on primary bg */
.role-user .message-content :deep(a) {
color: rgba(255, 255, 255, 0.9);
text-decoration: underline;
}
.role-user .message-content :deep(code) {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.role-user .message-content :deep(pre) {
background: rgba(0, 0, 0, 0.2);
}
.role-user .message-content :deep(blockquote) {
border-left-color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.85);
}
.message-actions {
display: flex;
@@ -180,9 +180,9 @@ defineExpose({ focus });
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="
store.chatReady
? 'Start a new chat... (Enter to send)'
: 'Chat unavailable'
!store.chatReady ? 'Chat unavailable'
: store.streaming ? 'Type to queue… (Enter to queue)'
: 'Start a new chat… (Enter to send)'
"
:disabled="!store.chatReady"
rows="1"
+131 -104
View File
@@ -1,130 +1,157 @@
<script setup lang="ts">
import type { Editor } from "@tiptap/vue-3";
const props = defineProps<{
editor: Editor | null;
}>();
const props = defineProps<{ editor: Editor | null }>();
const buttons = [
{
label: "B",
title: "Bold",
command: () => props.editor?.chain().focus().toggleBold().run(),
isActive: () => props.editor?.isActive("bold") ?? false,
},
{
label: "I",
title: "Italic",
command: () => props.editor?.chain().focus().toggleItalic().run(),
isActive: () => props.editor?.isActive("italic") ?? false,
},
{
label: "H1",
title: "Heading 1",
command: () =>
props.editor?.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => props.editor?.isActive("heading", { level: 1 }) ?? false,
},
{
label: "H2",
title: "Heading 2",
command: () =>
props.editor?.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => props.editor?.isActive("heading", { level: 2 }) ?? false,
},
{
label: "Link",
title: "Link",
command: () => {
const ed = props.editor;
if (!ed) return;
if (ed.isActive("link")) {
ed.chain().focus().unsetLink().run();
} else {
const url = prompt("URL:");
if (url) {
ed.chain().focus().setLink({ href: url }).run();
// SVG icons — Material Design paths (24x24) or custom SVG
const ICONS: Record<string, string> = {
bold: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>`,
italic: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>`,
strike: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/></svg>`,
h1: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">1</text></svg>`,
h2: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">2</text></svg>`,
h3: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">3</text></svg>`,
ul: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`,
ol: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2zm1-9h1V4H2v1h1zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2zm5-3h13V6H7v2zm0 4h13v-2H7v2zm0 4h13v-2H7v2z"/></svg>`,
task: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>`,
code: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>`,
codeblock: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M20 3H4v10c0 2.21 1.79 4 4 4h6c2.21 0 4-1.79 4-4v-3h2c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 5h-2V5h2v3zM4 19h16v2H4z"/></svg>`,
quote: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>`,
link: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`,
wikilink: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/></svg>`,
};
const groups = [
[
{ id: "bold", title: "Bold (Ctrl+B)", isActive: () => props.editor?.isActive("bold") ?? false, command: () => props.editor?.chain().focus().toggleBold().run() },
{ id: "italic", title: "Italic (Ctrl+I)", isActive: () => props.editor?.isActive("italic") ?? false, command: () => props.editor?.chain().focus().toggleItalic().run() },
{ id: "strike", title: "Strikethrough", isActive: () => props.editor?.isActive("strike") ?? false, command: () => props.editor?.chain().focus().toggleStrike().run() },
],
[
{ id: "h1", title: "Heading 1", isActive: () => props.editor?.isActive("heading", { level: 1 }) ?? false, command: () => props.editor?.chain().focus().toggleHeading({ level: 1 }).run() },
{ id: "h2", title: "Heading 2", isActive: () => props.editor?.isActive("heading", { level: 2 }) ?? false, command: () => props.editor?.chain().focus().toggleHeading({ level: 2 }).run() },
{ id: "h3", title: "Heading 3", isActive: () => props.editor?.isActive("heading", { level: 3 }) ?? false, command: () => props.editor?.chain().focus().toggleHeading({ level: 3 }).run() },
],
[
{ id: "ul", title: "Bullet List", isActive: () => props.editor?.isActive("bulletList") ?? false, command: () => props.editor?.chain().focus().toggleBulletList().run() },
{ id: "ol", title: "Ordered List", isActive: () => props.editor?.isActive("orderedList") ?? false, command: () => props.editor?.chain().focus().toggleOrderedList().run() },
{ id: "task", title: "Task List", isActive: () => props.editor?.isActive("taskList") ?? false, command: () => props.editor?.chain().focus().toggleTaskList().run() },
],
[
{ id: "code", title: "Inline Code", isActive: () => props.editor?.isActive("code") ?? false, command: () => props.editor?.chain().focus().toggleCode().run() },
{ id: "codeblock", title: "Code Block", isActive: () => props.editor?.isActive("codeBlock") ?? false, command: () => props.editor?.chain().focus().toggleCodeBlock().run() },
{ id: "quote", title: "Blockquote", isActive: () => props.editor?.isActive("blockquote") ?? false, command: () => props.editor?.chain().focus().toggleBlockquote().run() },
],
[
{
id: "link",
title: "Link",
isActive: () => props.editor?.isActive("link") ?? false,
command: () => {
const ed = props.editor;
if (!ed) return;
if (ed.isActive("link")) {
ed.chain().focus().unsetLink().run();
} else {
const url = prompt("URL:");
if (url) ed.chain().focus().setLink({ href: url }).run();
}
}
},
},
isActive: () => props.editor?.isActive("link") ?? false,
},
{
label: "UL",
title: "Bullet List",
command: () => props.editor?.chain().focus().toggleBulletList().run(),
isActive: () => props.editor?.isActive("bulletList") ?? false,
},
{
label: "OL",
title: "Numbered List",
command: () => props.editor?.chain().focus().toggleOrderedList().run(),
isActive: () => props.editor?.isActive("orderedList") ?? false,
},
{
label: "`",
title: "Inline Code",
command: () => props.editor?.chain().focus().toggleCode().run(),
isActive: () => props.editor?.isActive("code") ?? false,
},
{
label: "```",
title: "Code Block",
command: () => props.editor?.chain().focus().toggleCodeBlock().run(),
isActive: () => props.editor?.isActive("codeBlock") ?? false,
},
{
label: "☑",
title: "Task List",
command: () => props.editor?.chain().focus().toggleTaskList().run(),
isActive: () => props.editor?.isActive("taskList") ?? false,
},
{
label: "[[",
title: "Insert wikilink (type note title to search)",
command: () => props.editor?.chain().focus().insertContent("[[").run(),
isActive: () => false,
},
{
id: "wikilink",
title: "Insert wikilink [[ ]]",
isActive: () => false,
command: () => props.editor?.chain().focus().insertContent("[[").run(),
},
],
];
</script>
<template>
<div class="md-toolbar">
<button
v-for="btn in buttons"
:key="btn.label"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
@click="btn.command()"
>
{{ btn.label }}
</button>
<div class="md-toolbar" role="toolbar" aria-label="Text formatting">
<template v-for="(group, gi) in groups" :key="gi">
<div class="toolbar-group">
<button
v-for="btn in group"
:key="btn.id"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
@mousedown.prevent="btn.command()"
>
<span class="btn-icon" v-html="ICONS[btn.id]" />
</button>
</div>
<span v-if="gi < groups.length - 1" class="toolbar-sep" aria-hidden="true" />
</template>
</div>
</template>
<style scoped>
.md-toolbar {
display: flex;
align-items: center;
gap: 2px;
flex-wrap: wrap;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 3px 4px;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 1px;
}
.toolbar-sep {
display: block;
width: 1px;
height: 18px;
background: var(--color-border);
flex-shrink: 0;
margin: 0 3px;
}
.md-btn {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
flex-shrink: 0;
}
.md-btn:hover {
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
line-height: 1;
}
.md-btn:hover {
background: var(--color-bg-secondary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.md-btn.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
color: var(--color-primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent);
}
.md-btn.active:hover {
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
pointer-events: none;
}
</style>
+20 -3
View File
@@ -60,15 +60,16 @@ function goEdit() {
.note-card {
display: block;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
transition: box-shadow 0.15s;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.note-card:hover {
box-shadow: 0 2px 8px var(--color-shadow);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14);
transform: translateY(-2px);
}
/* Compact single-row layout */
@@ -77,6 +78,19 @@ function goEdit() {
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.75rem;
background: var(--color-bg-card);
box-shadow: none;
border-radius: 0;
border-bottom: 1px solid var(--color-border);
transform: none !important;
}
.note-card.compact:first-child {
border-top: 1px solid var(--color-border);
}
.note-card.compact:hover {
box-shadow: none;
background: rgba(99, 102, 241, 0.04);
transform: none;
}
.note-title-compact {
font-size: 0.9rem;
@@ -136,6 +150,9 @@ function goEdit() {
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
position: relative;
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
}
.note-meta {
display: flex;
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useNotificationsStore } from '@/stores/notifications'
import NotificationsPanel from './NotificationsPanel.vue'
const store = useNotificationsStore()
const open = ref(false)
const wrapRef = ref<HTMLElement | null>(null)
let pollInterval: ReturnType<typeof setInterval> | null = null
function toggle() {
open.value = !open.value
if (open.value) store.fetchAll()
}
function close() {
open.value = false
}
function onDocClick(e: MouseEvent) {
if (open.value && wrapRef.value && !wrapRef.value.contains(e.target as Node)) {
close()
}
}
onMounted(() => {
store.fetchCount()
pollInterval = setInterval(() => store.fetchCount(), 60_000)
document.addEventListener('click', onDocClick, true)
})
onUnmounted(() => {
if (pollInterval) clearInterval(pollInterval)
document.removeEventListener('click', onDocClick, true)
})
</script>
<template>
<div class="bell-wrap" ref="wrapRef">
<button
class="btn-bell"
:class="{ active: open }"
@click="toggle"
aria-label="Notifications"
:title="`${store.count} unread notification${store.count !== 1 ? 's' : ''}`"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span v-if="store.count > 0" class="bell-badge" aria-live="polite">{{ store.count > 99 ? '99+' : store.count }}</span>
</button>
<NotificationsPanel v-if="open" @close="close" />
</div>
</template>
<style scoped>
.bell-wrap {
position: relative;
}
.btn-bell {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.45rem;
cursor: pointer;
color: var(--color-text-muted);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.btn-bell:hover,
.btn-bell.active {
background: var(--color-bg-card);
color: var(--color-text);
border-color: var(--color-primary);
}
.bell-badge {
position: absolute;
top: -5px;
right: -5px;
background: var(--color-danger, #ef4444);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
line-height: 1;
pointer-events: none;
}
</style>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationsStore } from '@/stores/notifications'
import { relativeTime } from '@/composables/useRelativeTime'
const emit = defineEmits<{ close: [] }>()
const store = useNotificationsStore()
const router = useRouter()
const typeIcon: Record<string, string> = {
project_shared: '📁',
note_shared: '📝',
group_added: '👥',
}
async function handleClick(notif: { id: number; payload: Record<string, unknown> }) {
await store.markRead(notif.id)
const url = notif.payload.url as string | undefined
if (url) {
emit('close')
router.push(url)
}
}
onMounted(() => store.fetchAll())
</script>
<template>
<div class="notif-panel" role="dialog" aria-label="Notifications">
<header class="notif-panel-header">
<span class="notif-panel-title">Notifications</span>
<button
v-if="store.count > 0"
class="btn-mark-all"
@click="store.markAll()"
>Mark all read</button>
</header>
<ul class="notif-list" v-if="store.items.length">
<li
v-for="n in store.items"
:key="n.id"
class="notif-item"
@click="handleClick(n)"
>
<span class="notif-icon">{{ typeIcon[n.type] ?? '🔔' }}</span>
<div class="notif-body">
<p class="notif-msg">
<strong v-if="n.payload.invited_by">{{ n.payload.invited_by }}</strong>
{{ n.type === 'project_shared'
? ` shared "${n.payload.project_title}" with you as ${n.payload.permission}`
: n.type === 'note_shared'
? ` shared "${n.payload.note_title}" with you as ${n.payload.permission}`
: ` added you to "${n.payload.group_name}" as ${n.payload.role}` }}
</p>
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
</div>
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss"></button>
</li>
</ul>
<div v-else class="notif-empty">No unread notifications</div>
</div>
</template>
<style scoped>
.notif-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 340px;
max-height: 400px;
overflow-y: auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
z-index: 500;
}
.notif-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
background: var(--color-surface);
}
.notif-panel-title {
font-weight: 700;
font-size: 0.9rem;
}
.btn-mark-all {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.78rem;
cursor: pointer;
padding: 0;
}
.btn-mark-all:hover { text-decoration: underline; }
.notif-list {
list-style: none;
padding: 0;
margin: 0;
}
.notif-item {
display: flex;
align-items: flex-start;
gap: 0.6rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
transition: background 0.1s;
}
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: var(--color-hover); }
.notif-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 0.1rem; }
.notif-body { flex: 1; min-width: 0; }
.notif-msg {
margin: 0 0 0.2rem;
font-size: 0.85rem;
color: var(--color-text);
line-height: 1.4;
word-break: break-word;
}
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
.btn-notif-close {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
font-size: 0.8rem;
padding: 0.1rem 0.25rem;
flex-shrink: 0;
transition: color 0.1s;
}
.btn-notif-close:hover { color: var(--color-text); }
.notif-empty {
padding: 1.5rem;
text-align: center;
color: var(--color-muted);
font-size: 0.88rem;
font-style: italic;
}
</style>
+417
View File
@@ -0,0 +1,417 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import {
searchUsers,
listGroups,
listProjectShares,
createProjectShare,
updateProjectShare,
deleteProjectShare,
listNoteShares,
createNoteShare,
updateNoteShare,
deleteNoteShare,
type ShareEntry,
type GroupEntry,
type UserSearchResult,
} from '@/api/client'
const props = defineProps<{
resourceType: 'project' | 'note'
resourceId: number
resourceTitle: string
}>()
const emit = defineEmits<{ close: [] }>()
const addMode = ref<'user' | 'group'>('user')
const userQuery = ref('')
const userResults = ref<UserSearchResult[]>([])
const selectedUser = ref<UserSearchResult | null>(null)
const selectedGroupId = ref<number | ''>('')
const newPermission = ref('viewer')
const groups = ref<GroupEntry[]>([])
const shares = ref<ShareEntry[]>([])
const saving = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null
function debounceSearch() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(async () => {
if (userQuery.value.length < 2) { userResults.value = []; return }
userResults.value = await searchUsers(userQuery.value)
}, 300)
}
function selectUser(u: UserSearchResult) {
selectedUser.value = u
userQuery.value = u.username
userResults.value = []
}
async function loadShares() {
if (props.resourceType === 'project') {
shares.value = await listProjectShares(props.resourceId)
} else {
shares.value = await listNoteShares(props.resourceId)
}
}
async function addShare() {
if (saving.value) return
saving.value = true
try {
const body: { user_id?: number; group_id?: number; permission: string } = {
permission: newPermission.value,
}
if (addMode.value === 'user' && selectedUser.value) {
body.user_id = selectedUser.value.id
} else if (addMode.value === 'group' && selectedGroupId.value) {
body.group_id = selectedGroupId.value as number
} else {
return
}
if (props.resourceType === 'project') {
await createProjectShare(props.resourceId, body)
} else {
await createNoteShare(props.resourceId, body)
}
selectedUser.value = null
userQuery.value = ''
selectedGroupId.value = ''
newPermission.value = 'viewer'
await loadShares()
} finally {
saving.value = false
}
}
async function changePermission(share: ShareEntry, permission: string) {
if (props.resourceType === 'project') {
await updateProjectShare(props.resourceId, share.id, permission)
} else {
await updateNoteShare(props.resourceId, share.id, permission)
}
await loadShares()
}
async function removeShare(share: ShareEntry) {
if (props.resourceType === 'project') {
await deleteProjectShare(props.resourceId, share.id)
} else {
await deleteNoteShare(props.resourceId, share.id)
}
await loadShares()
}
onMounted(async () => {
groups.value = await listGroups()
await loadShares()
})
</script>
<template>
<Teleport to="body">
<div class="share-overlay" @click.self="emit('close')">
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
<header class="share-header">
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
<button class="btn-close" @click="emit('close')" aria-label="Close"></button>
</header>
<!-- Add share form -->
<div class="share-add">
<div class="share-tabs">
<button :class="['share-tab', { active: addMode === 'user' }]" @click="addMode = 'user'">User</button>
<button :class="['share-tab', { active: addMode === 'group' }]" @click="addMode = 'group'">Group</button>
</div>
<div v-if="addMode === 'user'" class="share-target-form">
<div class="user-search-wrap">
<input
v-model="userQuery"
class="share-input"
placeholder="Search by username or email…"
@input="debounceSearch"
autocomplete="off"
/>
<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>
<template v-if="selectedUser">
<select v-model="newPermission" class="perm-select">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button class="btn-add-share" @click="addShare" :disabled="saving">Add</button>
</template>
</div>
<div v-else class="share-target-form">
<select v-model="selectedGroupId" class="share-input">
<option value="" disabled>Select a group</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }} ({{ g.member_count }} members)</option>
</select>
<select v-model="newPermission" class="perm-select">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button class="btn-add-share" @click="addShare" :disabled="saving || !selectedGroupId">Add</button>
</div>
</div>
<!-- Current shares -->
<div class="shares-list-wrap">
<p class="shares-label">Current access</p>
<ul class="shares-list">
<li v-for="share in shares" :key="share.id" class="share-row">
<span class="share-target-icon">{{ share.shared_with_user_id ? '👤' : '👥' }}</span>
<span class="share-target-name">
{{ share.shared_with_user_id ? share.username : share.group_name }}
</span>
<select
class="perm-select-inline"
:value="share.permission"
@change="changePermission(share, ($event.target as HTMLSelectElement).value)"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove"></button>
</li>
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
</ul>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.share-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.share-dialog {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 480px;
max-width: 95vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.share-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.share-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.1rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.btn-close {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1.1rem;
padding: 0.25rem;
line-height: 1;
border-radius: 4px;
transition: color 0.15s;
}
.btn-close:hover { color: var(--color-text); }
.share-add {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.share-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.share-tab {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.3rem 0.8rem;
font-size: 0.82rem;
cursor: pointer;
color: var(--color-text-muted);
transition: all 0.15s;
}
.share-tab.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.share-target-form {
display: flex;
gap: 0.5rem;
align-items: flex-start;
flex-wrap: wrap;
}
.user-search-wrap {
flex: 1;
position: relative;
}
.share-input {
width: 100%;
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
outline: none;
transition: border-color 0.15s;
}
.share-input:focus { border-color: var(--color-primary); }
.user-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 6px;
margin-top: 2px;
list-style: none;
padding: 0.25rem 0;
z-index: 10;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
max-height: 180px;
overflow-y: auto;
}
.user-result-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.8rem;
cursor: pointer;
transition: background 0.1s;
}
.user-result-item:hover { background: var(--color-bg-secondary); }
.user-result-name { font-weight: 600; font-size: 0.88rem; }
.user-result-email { color: var(--color-text-muted); font-size: 0.8rem; }
.perm-select {
padding: 0.45rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
}
.btn-add-share {
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
white-space: nowrap;
}
.btn-add-share:disabled { opacity: 0.5; cursor: not-allowed; }
.shares-list-wrap {
padding: 1rem 1.5rem 1.5rem;
}
.shares-label {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin: 0 0 0.5rem;
}
.shares-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.share-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: var(--color-bg-secondary);
}
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
.share-target-name { flex: 1; font-size: 0.88rem; font-weight: 500; }
.perm-select-inline {
padding: 0.25rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.8rem;
cursor: pointer;
}
.btn-remove-share {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.9rem;
padding: 0.15rem 0.3rem;
border-radius: 4px;
transition: color 0.15s;
flex-shrink: 0;
}
.btn-remove-share:hover { color: var(--color-danger, #ef4444); }
.shares-empty {
color: var(--color-text-muted);
font-size: 0.85rem;
font-style: italic;
text-align: center;
padding: 0.75rem;
}
</style>
+6 -6
View File
@@ -37,16 +37,16 @@ const labels: Record<TaskStatus, string> = {
letter-spacing: 0.025em;
}
.status-todo {
background: var(--color-status-todo-bg);
color: var(--color-status-todo);
background: color-mix(in srgb, var(--color-status-todo-bg) 78%, var(--color-status-todo) 22%);
color: color-mix(in srgb, var(--color-status-todo) 85%, #000 15%);
}
.status-in_progress {
background: var(--color-status-in-progress-bg);
color: var(--color-status-in-progress);
background: color-mix(in srgb, var(--color-status-in-progress-bg) 78%, var(--color-status-in-progress) 22%);
color: color-mix(in srgb, var(--color-status-in-progress) 85%, #000 15%);
}
.status-done {
background: var(--color-status-done-bg);
color: var(--color-status-done);
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%);
}
.clickable {
cursor: pointer;
+3 -1
View File
@@ -36,9 +36,11 @@ defineEmits<{
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, background 0.15s;
}
.tag-pill:hover {
filter: brightness(0.95);
color: var(--color-primary);
background: rgba(99, 102, 241, 0.08);
}
.dismiss {
background: none;
+18 -4
View File
@@ -59,6 +59,14 @@ function isOverdue(): boolean {
<PriorityBadge :priority="task.priority!" />
<span class="task-title-compact">{{ task.title || "Untitled" }}</span>
<span v-if="projectTitle" class="project-crumb">{{ projectTitle }}</span>
<div class="task-tags-compact">
<TagPill
v-for="tag in task.tags?.slice(0, 2)"
:key="tag"
:tag="tag"
@click.stop="emit('tag-click', tag)"
/>
</div>
<span v-if="task.due_date" :class="['due-compact', { overdue: isOverdue() }]">
{{ task.due_date }}
</span>
@@ -97,15 +105,16 @@ function isOverdue(): boolean {
.task-card {
display: block;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
transition: box-shadow 0.15s;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(99, 102, 241, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.task-card:hover {
box-shadow: 0 2px 8px var(--color-shadow);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(99, 102, 241, 0.14);
transform: translateY(-2px);
}
/* Compact single-row layout */
@@ -113,7 +122,7 @@ function isOverdue(): boolean {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
padding: 0.45rem 0.85rem;
}
/* Status dot */
@@ -163,6 +172,11 @@ function isOverdue(): boolean {
white-space: nowrap;
flex-shrink: 0;
}
.task-tags-compact {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.due-compact {
font-size: 0.75rem;
color: var(--color-text-muted);
+3 -3
View File
@@ -3,12 +3,11 @@ import { watch, onBeforeUnmount } from "vue";
import { Editor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { Placeholder } from "@tiptap/extensions";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { serializeToMarkdown } from "@/utils/markdownSerializer";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import { TaskList, TaskItem } from "@tiptap/extension-list";
import { TagDecoration } from "@/extensions/TagDecoration";
import { WikilinkDecoration } from "@/extensions/WikilinkDecoration";
import { WikilinkSuggestion } from "@/extensions/WikilinkSuggestion";
@@ -71,6 +70,7 @@ try {
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
link: false,
}),
Link.configure({
openOnClick: false,
+95 -1
View File
@@ -9,9 +9,11 @@ const props = defineProps<{
const appliedTags = ref<Set<string>>(new Set());
const applyingTag = ref<string | null>(null);
const confirmState = ref<"idle" | "creating" | "created" | "denied">("idle");
const label = computed(() => {
if (props.toolCall.status === "declined") return "Declined";
if (props.toolCall.result.requires_confirmation) return "Similar content found";
if (!props.toolCall.result.success) return "Error";
switch (props.toolCall.result.type) {
case "task": return "Created task";
@@ -210,6 +212,33 @@ function toggle() {
if (hasDetail.value) collapsed.value = !collapsed.value;
}
async function confirmDuplicate() {
if (confirmState.value !== "idle") return;
confirmState.value = "creating";
const args = props.toolCall.arguments as Record<string, unknown>;
const isTask = props.toolCall.function === "create_task";
const endpoint = isTask ? "/api/tasks" : "/api/notes";
const payload: Record<string, unknown> = {
title: args.title,
body: args.body ?? "",
tags: args.tags ?? [],
...(args.due_date ? { due_date: args.due_date } : {}),
...(args.priority ? { priority: args.priority } : {}),
...(args.project ? { project: args.project } : {}),
...(isTask ? { status: args.status ?? "todo" } : {}),
};
try {
await apiPost(endpoint, payload);
confirmState.value = "created";
} catch {
confirmState.value = "idle";
}
}
function denyDuplicate() {
confirmState.value = "denied";
}
async function applyTag(tag: string) {
if (!noteId.value || appliedTags.value.has(tag) || applyingTag.value) return;
applyingTag.value = tag;
@@ -228,7 +257,8 @@ async function applyTag(tag: string) {
<div
class="tool-call-card"
:class="{
error: toolCall.status === 'error',
error: toolCall.status === 'error' && !toolCall.result.requires_confirmation,
'requires-confirm': toolCall.result.requires_confirmation,
declined: toolCall.status === 'declined',
running: toolCall.status === 'running',
collapsible: hasDetail,
@@ -244,6 +274,28 @@ async function applyTag(tag: string) {
{{ toolCall.arguments.title ?? toolCall.arguments.summary ?? toolCall.arguments.query ?? toolCall.function }}
</span>
</template>
<template v-else-if="toolCall.result.requires_confirmation">
<router-link
v-if="toolCall.result.similar_note"
:to="`/notes/${toolCall.result.similar_note.id}`"
class="tool-link"
@click.stop
>{{ toolCall.result.similar_note.title }}</router-link>
<span v-else class="tool-error">{{ toolCall.result.error }}</span>
<template v-if="confirmState === 'created'">
<span class="confirm-created"> Created</span>
</template>
<template v-else-if="confirmState === 'denied'">
<span class="confirm-denied">Skipped</span>
</template>
<template v-else-if="confirmState !== 'creating'">
<button class="btn-confirm-duplicate" @click.stop="confirmDuplicate">Create anyway</button>
<button class="btn-deny-duplicate" @click.stop="denyDuplicate">Skip</button>
</template>
<template v-else>
<span class="tool-summary-count">Creating</span>
</template>
</template>
<template v-else-if="toolCall.status === 'error'">
<span class="tool-error">{{ toolCall.result.error }}</span>
</template>
@@ -577,4 +629,46 @@ async function applyTag(tag: string) {
}
.tag-pill:disabled:not(.applied) { opacity: 0.6; cursor: wait; }
.tag-check { font-size: 0.65rem; }
/* Duplicate confirmation */
.tool-call-card.requires-confirm {
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 60%, transparent);
}
.confirm-created {
color: var(--color-success, #2ecc71);
font-size: 0.78rem;
font-weight: 600;
}
.confirm-denied {
color: var(--color-text-muted);
font-size: 0.78rem;
font-style: italic;
}
.btn-confirm-duplicate {
padding: 0.15rem 0.5rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm, 6px);
cursor: pointer;
font-size: 0.72rem;
font-family: inherit;
white-space: nowrap;
}
.btn-confirm-duplicate:hover { opacity: 0.9; }
.btn-deny-duplicate {
padding: 0.15rem 0.5rem;
background: none;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
cursor: pointer;
font-size: 0.72rem;
font-family: inherit;
white-space: nowrap;
}
.btn-deny-duplicate:hover {
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
</style>
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { apiGet } from "@/api/client";
import DiffView from "@/components/DiffView.vue";
import type { DiffLine } from "@/composables/useAssist";
interface NoteVersion {
id: number;
note_id: number;
title: string;
tags: string[];
body?: string;
created_at: string;
}
const props = defineProps<{
noteId: number;
currentBody: string;
}>();
const emit = defineEmits<{
(e: "restore", body: string, tags: string[]): void;
}>();
const expanded = ref(false);
const view = ref<"list" | "diff">("list");
const versions = ref<NoteVersion[]>([]);
const selectedVersion = ref<NoteVersion | null>(null);
const loading = ref(false);
const loadingDetail = ref(false);
const diff = computed<DiffLine[]>(() => {
if (!selectedVersion.value?.body) return [];
const aLines = props.currentBody.split("\n");
const bLines = selectedVersion.value.body.split("\n");
const m = aLines.length, n = bLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
const result: DiffLine[] = [];
let i = 0, j = 0;
while (i < m && j < n) {
if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] });
else result.push({ type: "insert", text: bLines[j++] });
}
while (i < m) result.push({ type: "delete", text: aLines[i++] });
while (j < n) result.push({ type: "insert", text: bLines[j++] });
return result;
});
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
}
return d.toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
async function toggle() {
expanded.value = !expanded.value;
if (expanded.value && versions.value.length === 0) {
await loadVersions();
}
}
async function loadVersions() {
loading.value = true;
try {
const data = await apiGet<{ versions: NoteVersion[] }>(
`/api/notes/${props.noteId}/versions`
);
versions.value = data.versions;
} catch {
// silent
} finally {
loading.value = false;
}
}
async function selectVersion(v: NoteVersion) {
if (v.body === undefined) {
loadingDetail.value = true;
try {
const full = await apiGet<NoteVersion>(
`/api/notes/${props.noteId}/versions/${v.id}`
);
const listItem = versions.value.find((x) => x.id === v.id);
if (listItem) listItem.body = full.body;
selectedVersion.value = full;
} catch {
// silent
} finally {
loadingDetail.value = false;
}
} else {
selectedVersion.value = v;
}
view.value = "diff";
}
function back() {
view.value = "list";
selectedVersion.value = null;
}
function restore() {
if (!selectedVersion.value?.body) return;
emit("restore", selectedVersion.value.body, selectedVersion.value.tags ?? []);
back();
}
</script>
<template>
<div class="vh-section">
<button class="vh-header" @click="toggle">
<span class="vh-title">Version History</span>
<span class="vh-chevron">{{ expanded ? "▴" : "▾" }}</span>
</button>
<div v-if="expanded" class="vh-body">
<!-- List view -->
<template v-if="view === 'list'">
<div v-if="loading" class="vh-empty">Loading...</div>
<div v-else-if="!versions.length" class="vh-empty">No snapshots yet.</div>
<div
v-for="v in versions"
:key="v.id"
class="vh-item"
@click="selectVersion(v)"
>
{{ formatDate(v.created_at) }}
</div>
</template>
<!-- Diff view -->
<template v-else>
<div class="vh-diff-actions">
<button class="vh-btn-back" @click="back"> Back</button>
<button
class="vh-btn-restore"
:disabled="!selectedVersion?.body"
@click="restore"
>Restore</button>
</div>
<div class="vh-diff-wrap">
<div v-if="loadingDetail" class="vh-empty">Loading...</div>
<DiffView v-else :diff="diff" />
</div>
</template>
</div>
</div>
</template>
<style scoped>
.vh-section {
border-top: 1px solid var(--color-border);
}
.vh-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.55rem 0.75rem;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
text-align: left;
}
.vh-header:hover { background: var(--color-bg-secondary); }
.vh-title {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vh-chevron {
font-size: 0.7rem;
color: var(--color-text-muted);
}
.vh-body {
padding: 0 0 0.5rem;
}
.vh-empty {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
.vh-item {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
color: var(--color-text);
cursor: pointer;
font-family: monospace;
border-left: 2px solid transparent;
}
.vh-item:hover {
background: var(--color-bg-secondary);
border-left-color: var(--color-primary);
}
.vh-diff-actions {
display: flex;
gap: 0.4rem;
padding: 0.4rem 0.75rem 0.35rem;
}
.vh-btn-back {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
color: var(--color-text-secondary);
cursor: pointer;
font-family: inherit;
}
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
.vh-btn-restore {
background: var(--color-primary);
border: none;
border-radius: var(--radius-sm);
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
color: #fff;
cursor: pointer;
font-family: inherit;
}
.vh-btn-restore:disabled { opacity: 0.5; cursor: default; }
.vh-diff-wrap {
padding: 0 0.5rem 0.25rem;
max-height: 420px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
</style>
+382 -393
View File
@@ -28,23 +28,18 @@ interface NoteItem {
const toast = useToastStore();
const notesStore = useNotesStore();
// View state: "list" or "editor"
const view = ref<"list" | "editor">("list");
// List state
const projectNotes = ref<NoteItem[]>([]);
const listLoading = ref(false);
const deletingId = ref<number | null>(null); // confirming delete for this id
const pendingDelete = ref<number | null>(null); // mid-deletion
const deletingId = ref<number | null>(null);
const pendingDelete = ref<number | null>(null);
const searchQuery = ref("");
const filteredNotes = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) return projectNotes.value;
return projectNotes.value.filter(
(n) =>
n.title.toLowerCase().includes(q) ||
n.tags?.some((t) => t.toLowerCase().includes(q))
(n) => n.title.toLowerCase().includes(q) || n.tags?.some((t) => t.toLowerCase().includes(q))
);
});
@@ -72,6 +67,8 @@ const newNoteTitle = ref("");
const creatingNote = ref(false);
const newNoteTitleRef = ref<HTMLInputElement | null>(null);
function _noteKey(pid: number) { return `workspace_note_${pid}`; }
async function startNewNote() {
showNewNoteInput.value = true;
newNoteTitle.value = "";
@@ -104,7 +101,7 @@ async function createNote() {
}
}
// TipTap editor ref (for MarkdownToolbar)
// TipTap editor ref
const titleRef = ref<HTMLInputElement | null>(null);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const tiptapEditor = computed<Editor | null>(() =>
@@ -184,7 +181,7 @@ async function openNote(id: number) {
dirty.value = false;
tagSuggestions.dismissTagSuggestions();
linkSuggestions.value = [];
view.value = "editor";
localStorage.setItem(_noteKey(props.projectId), String(id));
fetchLinkSuggestions();
emit("note-loaded", note.id);
} catch {
@@ -202,7 +199,6 @@ async function saveNote() {
tags: noteTags.value,
});
dirty.value = false;
// Refresh updated_at in the list
const idx = projectNotes.value.findIndex((n) => n.id === editingId.value);
if (idx !== -1) {
projectNotes.value[idx] = {
@@ -211,7 +207,6 @@ async function saveNote() {
tags: noteTags.value,
updated_at: new Date().toISOString(),
};
// Re-sort by updated_at
projectNotes.value.sort((a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
@@ -223,16 +218,9 @@ async function saveNote() {
}
}
function backToList() {
view.value = "list";
deletingId.value = null;
searchQuery.value = "";
}
function requestDelete(id: number, e: Event) {
e.stopPropagation();
if (deletingId.value === id) {
// Second click — confirm
confirmDelete(id);
} else {
deletingId.value = id;
@@ -253,7 +241,7 @@ async function confirmDelete(id: number) {
editingId.value = null;
noteTitle.value = "";
noteBody.value = "";
view.value = "list";
localStorage.removeItem(_noteKey(props.projectId));
}
} catch {
toast.show("Failed to delete note", "error");
@@ -292,93 +280,27 @@ watch(
);
useAutoSave(dirty, saving, saveNote, 60_000);
onMounted(loadProjectNotes);
onMounted(async () => {
await loadProjectNotes();
const stored = localStorage.getItem(_noteKey(props.projectId));
if (stored) {
const id = Number(stored);
if (projectNotes.value.find((n) => n.id === id)) {
openNote(id).catch(() => {});
}
}
});
onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
</script>
<template>
<div class="ws-note-editor">
<!-- EDITOR VIEW -->
<template v-if="view === 'editor'">
<div class="panel-header">
<button class="btn-back-arrow" @click="backToList"> Notes</button>
<div class="editor-header-right">
<WordCount :body="noteBody" />
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
<span v-if="saving" class="saving-txt">Saving...</span>
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
</div>
</div>
<div class="note-title-row">
<input
ref="titleRef"
v-model="noteTitle"
class="note-title-input"
placeholder="Note title"
@keydown.ctrl.s.prevent="saveNote"
@keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()"
/>
</div>
<div class="tag-row">
<TagInput
v-model="noteTags"
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
/>
<button
class="btn-suggest-tags"
:disabled="tagSuggestions.suggestingTags.value"
title="Auto-suggest tags from title and body"
@click="tagSuggestions.fetchTagSuggestions()"
>
{{ tagSuggestions.suggestingTags.value ? '...' : 'Suggest' }}
</button>
</div>
<!-- Tag suggestions strip -->
<div v-if="tagSuggestions.suggestedTags.value.length" class="tag-suggestions">
<span class="tag-suggestions-label">Suggested:</span>
<button
v-for="tag in tagSuggestions.suggestedTags.value"
:key="tag"
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
@click="tagSuggestions.applyTagSuggestion(tag)"
>
#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}
</button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"></button>
</div>
<!-- Formatting toolbar -->
<div class="toolbar-row">
<MarkdownToolbar :editor="tiptapEditor" />
</div>
<!-- Link suggestions strip -->
<div v-if="linkSuggestions.length" class="link-suggest-strip">
<span class="link-suggest-label">Links:</span>
<span
v-for="s in linkSuggestions"
:key="s.note_id"
class="link-suggest-chip"
:title="`Appears ${s.count}× unlinked`"
>
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
</span>
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"></button>
</div>
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
<TiptapEditor ref="editorRef" v-model="noteBody" @escape="titleRef?.focus()" />
</div>
</template>
<!-- LIST VIEW -->
<template v-else>
<div class="panel-header">
<!-- Left rail: always-visible note list -->
<div class="note-rail">
<div class="rail-header">
<template v-if="showNewNoteInput">
<input
ref="newNoteTitleRef"
@@ -389,77 +311,134 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
@keydown.enter="createNote"
@keydown.escape="cancelNewNote"
/>
<button class="btn-new-note-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
{{ creatingNote ? '...' : '+' }}
<button class="btn-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
{{ creatingNote ? '' : '+' }}
</button>
<button class="btn-new-note-cancel" aria-label="Cancel new note" @click="cancelNewNote"></button>
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"></button>
</template>
<template v-else>
<span class="panel-title">Notes</span>
<span class="rail-title">Notes</span>
<button class="btn-new-note" @click="startNewNote" title="New note">+ New</button>
</template>
</div>
<div class="note-search-bar">
<div class="rail-search">
<input
v-model="searchQuery"
class="note-search-input"
placeholder="Search notes..."
class="rail-search-input"
placeholder="Search"
type="search"
aria-label="Search notes"
/>
<button v-if="searchQuery" class="btn-search-clear" @click="searchQuery = ''" title="Clear search"></button>
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"></button>
</div>
<div v-if="listLoading" class="state-msg">Loading...</div>
<div v-if="listLoading" class="rail-state">Loading</div>
<ul v-else class="note-list">
<li
v-for="note in filteredNotes"
:key="note.id"
class="note-row"
:class="{ active: editingId === note.id }"
:class="['note-row', { active: editingId === note.id }]"
@click="openNote(note.id)"
>
<div class="note-row-main">
<span class="note-row-title">{{ note.title }}</span>
<span v-if="note.tags?.length" class="note-row-tags">
<span
v-for="tag in note.tags.slice(0, 3)"
:key="tag"
:class="['note-tag-pill', { 'tag-match': matchedTags(note).includes(tag) }]"
>#{{ tag }}</span>
<span v-if="note.tags.length > 3" class="note-tag-more">+{{ note.tags.length - 3 }}</span>
</span>
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
</div>
<div v-if="note.tags?.length" class="note-row-tags">
<span
v-for="tag in note.tags.slice(0, 2)"
:key="tag"
:class="['note-tag-pill', { 'tag-match': matchedTags(note).includes(tag) }]"
>#{{ tag }}</span>
<span v-if="note.tags.length > 2" class="note-tag-more">+{{ note.tags.length - 2 }}</span>
</div>
<div class="note-row-actions" @click.stop>
<template v-if="deletingId === note.id">
<button
class="btn-confirm-delete"
:disabled="pendingDelete === note.id"
@click="requestDelete(note.id, $event)"
>
{{ pendingDelete === note.id ? '...' : 'Delete?' }}
<button class="btn-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
{{ pendingDelete === note.id ? '' : 'Delete?' }}
</button>
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"></button>
</template>
<button
v-else
class="btn-delete"
title="Delete note"
@click="requestDelete(note.id, $event)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>
</li>
<li v-if="filteredNotes.length === 0" class="state-msg">
{{ searchQuery.trim() ? 'No notes match.' : 'No notes yet ask the agent to create one.' }}
<li v-if="filteredNotes.length === 0" class="rail-state">
{{ searchQuery.trim() ? 'No matches.' : 'No notes yet.' }}
</li>
</ul>
</template>
</div>
<!-- Right pane: editor -->
<div class="note-editor-pane">
<template v-if="editingId">
<div class="panel-header">
<div class="editor-header-right">
<WordCount :body="noteBody" />
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
<span v-if="saving" class="saving-txt">Saving</span>
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
</div>
</div>
<div class="note-title-row">
<input
ref="titleRef"
v-model="noteTitle"
class="note-title-input"
placeholder="Note title"
@keydown.ctrl.s.prevent="saveNote"
@keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()"
/>
</div>
<div class="tag-row">
<TagInput v-model="noteTags" :fetchTags="(q: string) => notesStore.fetchAllTags(q)" />
<button
class="btn-suggest-tags"
:disabled="tagSuggestions.suggestingTags.value"
title="Auto-suggest tags from title and body"
@click="tagSuggestions.fetchTagSuggestions()"
>
{{ tagSuggestions.suggestingTags.value ? '…' : 'Suggest' }}
</button>
</div>
<div v-if="tagSuggestions.suggestedTags.value.length" class="tag-suggestions">
<span class="tag-suggestions-label">Suggested:</span>
<button
v-for="tag in tagSuggestions.suggestedTags.value"
:key="tag"
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
@click="tagSuggestions.applyTagSuggestion(tag)"
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"></button>
</div>
<div class="toolbar-row">
<MarkdownToolbar :editor="tiptapEditor" />
</div>
<div v-if="linkSuggestions.length" class="link-suggest-strip">
<span class="link-suggest-label">Links:</span>
<span v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-chip" :title="`Appears ${s.count}× unlinked`">
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
</span>
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"></button>
</div>
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
<TiptapEditor ref="editorRef" v-model="noteBody" @escape="titleRef?.focus()" />
</div>
</template>
<div v-else class="editor-empty-state">
<p>Select a note or create a new one</p>
</div>
</div>
</div>
</template>
@@ -467,41 +446,278 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
<style scoped>
.ws-note-editor {
display: flex;
flex-direction: column;
flex-direction: row;
height: 100%;
overflow: hidden;
background: var(--color-surface);
border-left: 1px solid var(--color-border);
}
/* ── Left rail ── */
.note-rail {
width: 155px;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--color-border);
}
.rail-header {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.rail-title {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
font-weight: 600;
flex: 1;
}
.btn-new-note {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
}
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
.rail-search {
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.rail-search-input {
flex: 1;
background: transparent;
border: none;
font-size: 0.78rem;
color: var(--color-text);
min-width: 0;
padding: 0;
}
.rail-search-input:focus { outline: none; }
.rail-search-input::-webkit-search-cancel-button { display: none; }
.btn-search-clear {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.68rem;
cursor: pointer;
padding: 0;
line-height: 1;
flex-shrink: 0;
}
.btn-search-clear:hover { color: var(--color-text); }
.rail-state {
padding: 1rem 0.65rem;
font-size: 0.78rem;
color: var(--color-text-muted);
font-style: italic;
}
/* Note list */
.note-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
flex: 1;
}
.note-row {
display: flex;
flex-direction: column;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
cursor: pointer;
gap: 0.15rem;
border-left: 2px solid transparent;
transition: background 0.12s;
}
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
.note-row.active {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border-left-color: var(--color-primary);
}
.note-row-main {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.note-row-title {
flex: 1;
font-size: 0.78rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
}
.note-row-age {
font-size: 0.62rem;
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.note-row-tags {
display: flex;
gap: 0.15rem;
overflow: hidden;
}
.note-tag-pill {
font-size: 0.58rem;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-radius: 999px;
padding: 0 0.3rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 5rem;
}
.note-tag-pill.tag-match {
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
outline: 1px solid var(--color-primary);
}
.note-tag-more {
font-size: 0.58rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.note-row-actions {
display: flex;
gap: 0.2rem;
align-items: center;
}
.btn-delete {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 0.1rem;
border-radius: 3px;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
display: flex;
align-items: center;
}
.note-row:hover .btn-delete { opacity: 1; }
.btn-delete:hover { color: var(--color-danger, #e74c3c); }
.btn-confirm-delete {
background: none;
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
font-size: 0.65rem;
font-weight: 600;
cursor: pointer;
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
.btn-cancel-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.7rem;
cursor: pointer;
padding: 0.1rem;
}
.btn-cancel-delete:hover { color: var(--color-text); }
/* Inline new note */
.new-note-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-primary);
border-radius: 4px;
padding: 0.15rem 0.35rem;
font-size: 0.78rem;
color: var(--color-text);
min-width: 0;
}
.new-note-input:focus { outline: none; }
.btn-confirm {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 4px;
padding: 0.15rem 0.35rem;
font-size: 0.85rem;
cursor: pointer;
flex-shrink: 0;
line-height: 1;
}
.btn-confirm:disabled { opacity: 0.4; cursor: default; }
.btn-cancel {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.72rem;
cursor: pointer;
padding: 0.1rem;
flex-shrink: 0;
}
.btn-cancel:hover { color: var(--color-text); }
/* ── Right editor pane ── */
.note-editor-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.editor-empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
font-size: 0.85rem;
font-style: italic;
}
/* Editor UI */
.panel-header {
display: flex;
align-items: center;
padding: 0.6rem 0.75rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
gap: 0.5rem;
}
.panel-title {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
font-weight: 600;
}
.btn-back-arrow {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.85rem;
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.btn-back-arrow:hover { text-decoration: underline; }
.editor-header-right {
display: flex;
align-items: center;
@@ -523,7 +739,6 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
}
.btn-save:disabled { opacity: 0.4; cursor: default; }
/* Editor */
.note-title-row {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
@@ -549,11 +764,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.tag-row > :first-child {
flex: 1;
min-width: 0;
}
.tag-row > :first-child { flex: 1; min-width: 0; }
.btn-suggest-tags {
background: none;
@@ -567,10 +778,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
flex-shrink: 0;
align-self: center;
}
.btn-suggest-tags:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-suggest-tags:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
.btn-suggest-tags:disabled { opacity: 0.5; cursor: default; }
.tag-suggestions {
@@ -583,12 +791,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
.tag-suggestions-label {
font-size: 0.72rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.tag-suggestions-label { font-size: 0.72rem; color: var(--color-text-muted); flex-shrink: 0; }
.btn-tag-suggestion {
background: none;
@@ -633,7 +836,6 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
.link-suggest-label {
font-size: 0.7rem;
color: var(--color-text-muted);
@@ -642,10 +844,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
text-transform: uppercase;
letter-spacing: 0.04em;
}
.link-suggest-chip {
display: inline-flex;
}
.link-suggest-chip { display: inline-flex; }
.btn-chip-link {
background: none;
@@ -658,9 +857,7 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
font-family: monospace;
white-space: nowrap;
}
.btn-chip-link:hover {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
.btn-link-all {
background: none;
@@ -679,212 +876,4 @@ onUnmounted(() => { if (linkCheckTimer) clearTimeout(linkCheckTimer); });
overflow-y: auto;
padding: 0.5rem 0.6rem;
}
/* Search bar */
.note-search-bar {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.note-search-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.25rem 0.5rem;
font-size: 0.82rem;
color: var(--color-text);
min-width: 0;
}
.note-search-input:focus { outline: none; border-color: var(--color-primary); }
.note-search-input::-webkit-search-cancel-button { display: none; }
.btn-search-clear {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.72rem;
cursor: pointer;
padding: 0.1rem 0.25rem;
flex-shrink: 0;
border-radius: 3px;
}
.btn-search-clear:hover { color: var(--color-text); }
/* List */
.note-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
flex: 1;
}
.note-row {
display: flex;
flex-direction: column;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
gap: 0.3rem;
}
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
.note-row.active { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
.note-row-main {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.note-row-title {
flex: 1;
font-size: 0.85rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
}
.note-row-tags {
display: flex;
align-items: center;
gap: 0.2rem;
flex-shrink: 0;
overflow: hidden;
max-width: 40%;
}
.note-tag-pill {
font-size: 0.62rem;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-radius: 999px;
padding: 0 0.35rem;
white-space: nowrap;
line-height: 1.6;
overflow: hidden;
text-overflow: ellipsis;
max-width: 6rem;
}
.note-tag-pill.tag-match {
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
outline: 1px solid var(--color-primary);
}
.note-tag-more {
font-size: 0.62rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.note-row-age {
font-size: 0.68rem;
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.note-row-actions {
display: flex;
gap: 0.3rem;
align-items: center;
}
.btn-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
border-radius: 3px;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.note-row:hover .btn-delete { opacity: 1; }
.btn-delete:hover { color: var(--color-danger, #e74c3c); }
.btn-confirm-delete {
background: none;
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
padding: 0.15rem 0.5rem;
border-radius: 4px;
}
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
.btn-cancel-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
}
.btn-cancel-delete:hover { color: var(--color-text); }
.state-msg {
padding: 1.5rem;
text-align: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.btn-new-note {
margin-left: auto;
background: none;
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0.2rem 0.55rem;
font-size: 0.75rem;
color: var(--color-text-muted);
cursor: pointer;
}
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
.new-note-input {
flex: 1;
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-primary);
border-radius: 5px;
padding: 0.22rem 0.5rem;
font-size: 0.83rem;
color: var(--color-text);
min-width: 0;
}
.new-note-input:focus { outline: none; }
.btn-new-note-confirm {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 5px;
padding: 0.22rem 0.5rem;
font-size: 0.9rem;
cursor: pointer;
flex-shrink: 0;
line-height: 1;
}
.btn-new-note-confirm:disabled { opacity: 0.4; cursor: default; }
.btn-new-note-cancel {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
flex-shrink: 0;
}
.btn-new-note-cancel:hover { color: var(--color-text); }
</style>
+144 -133
View File
@@ -56,6 +56,17 @@ const STATUS_ICON: Record<string, string> = {
done: "✓",
};
const PRIORITY_CLASS: Record<string, string> = {
high: "priority-high",
medium: "priority-medium",
low: "priority-low",
};
function isRowOverdue(task: Task): boolean {
if (!task.due_date || task.status === "done") return false;
return task.due_date < new Date().toISOString().slice(0, 10);
}
// Group tasks by milestone, "No Milestone" first
const groupedTasks = computed(() => {
const noMs = tasks.value.filter((t) => t.milestone_id === null);
@@ -207,45 +218,99 @@ defineExpose({ reload: loadAll });
<template>
<div class="ws-task-panel">
<!-- Slide-over detail view -->
<Transition name="slide">
<!-- Task list always visible, shrinks when detail is open -->
<div :class="['task-list-view', { 'has-detail': !!activeTask }]">
<div class="panel-header">
<span class="panel-title">Tasks</span>
</div>
<div class="task-add">
<input
v-model="newTaskTitle"
class="task-add-input"
placeholder="New task..."
@keydown.enter="addTask"
/>
<button class="btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
</div>
<div v-if="loading" class="state-msg">Loading...</div>
<div v-else class="groups-scroll">
<!-- No Milestone group (always first) -->
<div class="ms-group">
<button class="ms-group-header" @click="toggleGroup(null)">
<span class="ms-chevron">{{ collapsedGroups.has(null) ? '▶' : '▼' }}</span>
<span class="ms-name">No Milestone</span>
<span class="ms-count">{{ groupedTasks.noMilestone.length }}</span>
</button>
<ul v-show="!collapsedGroups.has(null)" class="task-items">
<li
v-for="task in groupedTasks.noMilestone"
:key="task.id"
:class="['task-row', { 'task-active': activeTask?.id === task.id }]"
@click="openTask(task)"
>
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '' }}</button>
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
</li>
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
</ul>
</div>
<!-- Milestone groups -->
<div v-for="{ milestone, tasks: msTasks } in groupedTasks.milestoneGroups" :key="milestone.id" class="ms-group">
<button class="ms-group-header" @click="toggleGroup(milestone.id)">
<span class="ms-chevron">{{ collapsedGroups.has(milestone.id) ? '▶' : '▼' }}</span>
<span class="ms-name">{{ milestone.title }}</span>
<span :class="['ms-status', `ms-status-${milestone.status}`]">{{ milestone.status.replace('_',' ') }}</span>
<span class="ms-count">{{ msTasks.length }}</span>
</button>
<ul v-show="!collapsedGroups.has(milestone.id)" class="task-items">
<li
v-for="task in msTasks"
:key="task.id"
:class="['task-row', { 'task-active': activeTask?.id === task.id }]"
@click="openTask(task)"
>
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '' }}</button>
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
</li>
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
</ul>
</div>
</div>
</div>
<!-- Detail pane bottom split when a task is selected -->
<Transition name="detail-fade">
<div v-if="activeTask" class="task-detail">
<div class="detail-header">
<button class="btn-back-arrow" @click="closeTask"> Tasks</button>
<RouterLink
:to="`/tasks/${activeTask.id}/edit`"
target="_blank"
class="btn-edit-task"
title="Open full editor"
>Edit </RouterLink>
<span
:class="['status-badge', `status-${activeTask.status}`]"
@click="cycleStatus(activeTask, $event)"
title="Click to cycle status"
>
{{ STATUS_ICON[activeTask.status] ?? "○" }}
{{ activeTask.status.replace("_", " ") }}
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-edit-task" title="Open full editor">Edit </RouterLink>
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
</span>
<template v-if="deleteConfirmPending">
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">
{{ deletingTask ? '...' : 'Delete?' }}
</button>
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"></button>
</template>
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail"></button>
</div>
<h3 class="detail-title">{{ activeTask.title }}</h3>
<div class="detail-meta">
<span v-if="activeTask.priority && activeTask.priority !== 'none'" class="meta-chip priority">
{{ activeTask.priority }}
</span>
<span v-if="activeTask.due_date" class="meta-chip due">
Due {{ activeTask.due_date }}
</span>
<span v-if="activeTask.priority && activeTask.priority !== 'none'" class="meta-chip priority">{{ activeTask.priority }}</span>
<span v-if="activeTask.due_date" class="meta-chip due">Due {{ activeTask.due_date }}</span>
<select
class="milestone-select"
:value="activeTask.milestone_id ?? ''"
@@ -268,90 +333,6 @@ defineExpose({ reload: loadAll });
</div>
</Transition>
<!-- Task list (always mounted, hidden during slide-over via v-show) -->
<div v-show="!activeTask" class="task-list-view">
<div class="panel-header">
<span class="panel-title">Tasks</span>
</div>
<div class="task-add">
<input
v-model="newTaskTitle"
class="task-add-input"
placeholder="New task..."
@keydown.enter="addTask"
/>
<button class="btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
</div>
<div v-if="loading" class="state-msg">Loading...</div>
<div v-else class="groups-scroll">
<!-- No Milestone group (always first) -->
<div class="ms-group">
<button
class="ms-group-header"
@click="toggleGroup(null)"
>
<span class="ms-chevron">{{ collapsedGroups.has(null) ? '▶' : '▼' }}</span>
<span class="ms-name">No Milestone</span>
<span class="ms-count">{{ groupedTasks.noMilestone.length }}</span>
</button>
<ul v-show="!collapsedGroups.has(null)" class="task-items">
<li
v-for="task in groupedTasks.noMilestone"
:key="task.id"
class="task-row"
@click="openTask(task)"
>
<button
:class="['status-dot', `status-${task.status}`]"
:title="`${task.status} — click to cycle`"
@click="cycleStatus(task, $event)"
>{{ STATUS_ICON[task.status] ?? '○' }}</button>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
</li>
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
</ul>
</div>
<!-- Milestone groups -->
<div
v-for="{ milestone, tasks: msTasks } in groupedTasks.milestoneGroups"
:key="milestone.id"
class="ms-group"
>
<button
class="ms-group-header"
@click="toggleGroup(milestone.id)"
>
<span class="ms-chevron">{{ collapsedGroups.has(milestone.id) ? '▶' : '▼' }}</span>
<span class="ms-name">{{ milestone.title }}</span>
<span :class="['ms-status', `ms-status-${milestone.status}`]">{{ milestone.status.replace('_',' ') }}</span>
<span class="ms-count">{{ msTasks.length }}</span>
</button>
<ul v-show="!collapsedGroups.has(milestone.id)" class="task-items">
<li
v-for="task in msTasks"
:key="task.id"
class="task-row"
@click="openTask(task)"
>
<button
:class="['status-dot', `status-${task.status}`]"
:title="`${task.status} — click to cycle`"
@click="cycleStatus(task, $event)"
>{{ STATUS_ICON[task.status] ?? '○' }}</button>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
</li>
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
</ul>
</div>
</div>
</div>
</div>
</template>
@@ -370,9 +351,16 @@ defineExpose({ reload: loadAll });
.task-list-view {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
}
.task-list-view.has-detail {
flex: 0 0 44%;
}
.task-active {
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface)) !important;
}
.panel-header {
padding: 0.6rem 0.75rem;
@@ -510,15 +498,15 @@ defineExpose({ reload: loadAll });
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
/* ── Slide-over detail ── */
/* ── Detail pane (bottom split) ── */
.task-detail {
position: absolute;
inset: 0;
background: var(--color-surface);
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 5;
background: var(--color-surface);
border-top: 2px solid var(--color-border);
}
.detail-header {
@@ -530,17 +518,6 @@ defineExpose({ reload: loadAll });
flex-shrink: 0;
}
.btn-back-arrow {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.85rem;
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.btn-back-arrow:hover { text-decoration: underline; }
.status-badge {
padding: 0.2rem 0.55rem;
border-radius: 12px;
@@ -669,11 +646,45 @@ defineExpose({ reload: loadAll });
border-top: 1px solid var(--color-border);
}
/* Slide transition */
.slide-enter-active,
.slide-leave-active {
transition: transform 0.2s ease;
/* Detail fade transition */
.detail-fade-enter-active,
.detail-fade-leave-active { transition: opacity 0.15s; }
.detail-fade-enter-from,
.detail-fade-leave-to { opacity: 0; }
/* Priority dots on task rows */
.priority-dot {
width: 6px;
height: 6px;
border-radius: 2px;
flex-shrink: 0;
}
.slide-enter-from { transform: translateX(100%); }
.slide-leave-to { transform: translateX(100%); }
.priority-high { background: #ef4444; }
.priority-medium { background: #f59e0b; }
.priority-low { background: #3b82f6; }
/* Due date on task rows */
.task-due {
font-size: 0.65rem;
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.task-due.overdue {
color: var(--color-danger, #e74c3c);
font-weight: 600;
}
/* Close detail button */
.btn-close-detail {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
padding: 0.15rem 0.3rem;
border-radius: 3px;
margin-left: 0.2rem;
}
.btn-close-detail:hover { color: var(--color-text); }
</style>
+1 -1
View File
@@ -1,4 +1,4 @@
import { Extension } from "@tiptap/vue-3";
import { Extension } from "@tiptap/core";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion from "@tiptap/suggestion";
import { createSuggestionRenderer } from "./suggestionRenderer";
+1 -1
View File
@@ -1,4 +1,4 @@
import { Extension } from "@tiptap/vue-3";
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
+1 -1
View File
@@ -1,4 +1,4 @@
import { Extension } from "@tiptap/vue-3";
import { Extension } from "@tiptap/core";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion from "@tiptap/suggestion";
import { createSuggestionRenderer } from "./suggestionRenderer";
@@ -1,4 +1,4 @@
import { Extension } from "@tiptap/vue-3";
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
@@ -1,4 +1,4 @@
import { Extension } from "@tiptap/vue-3";
import { Extension } from "@tiptap/core";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion from "@tiptap/suggestion";
import { createSuggestionRenderer } from "./suggestionRenderer";
+12 -10
View File
@@ -105,21 +105,23 @@ const router = createRouter({
name: "chat-conversation",
component: () => import("@/views/ChatView.vue"),
},
{
path: "/shared",
name: "shared-with-me",
component: () => import("@/views/SharedWithMeView.vue"),
},
{
path: "/briefing",
name: "briefing",
component: () => import("@/views/BriefingView.vue"),
},
{
path: "/settings",
name: "settings",
component: () => import("@/views/SettingsView.vue"),
},
{
path: "/admin/users",
name: "admin-users",
component: () => import("@/views/UserManagementView.vue"),
},
{
path: "/admin/logs",
name: "admin-logs",
component: () => import("@/views/LogsView.vue"),
},
{ path: "/admin/users", redirect: "/settings" },
{ path: "/admin/logs", redirect: "/settings" },
],
});
+91 -1
View File
@@ -30,12 +30,24 @@ interface ConvStreamState {
contextMeta: ContextMeta | null;
}
interface QueuedMessage {
content: string;
contextNoteId?: number | null;
includeNoteIds?: number[];
think: boolean;
contextNoteTitle?: string;
excludeNoteIds?: number[];
ragProjectId?: number | null;
workspaceProjectId?: number | null;
}
export const useChatStore = defineStore("chat", () => {
const conversations = ref<Conversation[]>([]);
const currentConversation = ref<ConversationDetail | null>(null);
const total = ref(0);
const loading = ref(false);
const convStreams = ref<Record<number, ConvStreamState>>({});
const convQueues = ref<Record<number, QueuedMessage[]>>({});
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
const defaultModel = ref("");
@@ -67,6 +79,44 @@ export const useChatStore = defineStore("chat", () => {
return convStreams.value[id]?.streaming ?? false;
}
const queuedCount = computed(() => {
const id = currentConversation.value?.id;
return id ? (convQueues.value[id]?.length ?? 0) : 0;
});
const queuedMessages = computed(() => {
const id = currentConversation.value?.id;
return id ? (convQueues.value[id] ?? []) : [];
});
function _saveQueue(convId: number) {
const q = convQueues.value[convId] ?? [];
if (q.length) {
localStorage.setItem(`fa_conv_queue_${convId}`, JSON.stringify(q));
} else {
localStorage.removeItem(`fa_conv_queue_${convId}`);
}
}
function _loadQueue(convId: number) {
try {
const raw = localStorage.getItem(`fa_conv_queue_${convId}`);
if (raw) {
convQueues.value[convId] = JSON.parse(raw) as QueuedMessage[];
}
} catch {
// ignore corrupt data
}
}
function clearQueue() {
const id = currentConversation.value?.id;
if (id) {
convQueues.value[id] = [];
_saveQueue(id);
}
}
// Chat is usable when Ollama is up and model is at least installed (cold starts still work)
const chatReady = computed(
() => ollamaStatus.value === "available" && (modelStatus.value === "loaded" || modelStatus.value === "cold")
@@ -107,6 +157,7 @@ export const useChatStore = defineStore("chat", () => {
currentConversation.value = await apiGet<ConversationDetail>(
`/api/chat/conversations/${id}`
);
_loadQueue(id);
} catch (e) {
useToastStore().show("Failed to load conversation", "error");
throw e;
@@ -123,7 +174,11 @@ export const useChatStore = defineStore("chat", () => {
if (currentConversation.value && idSet.has(currentConversation.value.id)) {
currentConversation.value = null;
}
for (const id of ids) delete convStreams.value[id];
for (const id of ids) {
delete convStreams.value[id];
delete convQueues.value[id];
localStorage.removeItem(`fa_conv_queue_${id}`);
}
}
async function deleteConversation(id: number) {
@@ -134,6 +189,8 @@ export const useChatStore = defineStore("chat", () => {
currentConversation.value = null;
}
delete convStreams.value[id];
delete convQueues.value[id];
localStorage.removeItem(`fa_conv_queue_${id}`);
} catch (e) {
useToastStore().show("Failed to delete conversation", "error");
throw e;
@@ -173,6 +230,18 @@ export const useChatStore = defineStore("chat", () => {
const convId = currentConversation.value.id;
const s = _getOrInitStream(convId);
// If already streaming, queue the message for after the current stream ends.
if (s.streaming) {
if (!convQueues.value[convId]) convQueues.value[convId] = [];
convQueues.value[convId].push({
content, contextNoteId, includeNoteIds, think, contextNoteTitle,
excludeNoteIds, ragProjectId, workspaceProjectId,
});
_saveQueue(convId);
return;
}
s.contextMeta = null;
// If a write tool is waiting for confirmation, intercept single-word yes/no
@@ -361,6 +430,24 @@ export const useChatStore = defineStore("chat", () => {
s.status = "";
s.pendingTool = null;
}
// Process next queued message, if any.
// Use setTimeout so this frame resolves before the next send begins.
const queue = convQueues.value[convId];
if (queue?.length && currentConversation.value?.id === convId) {
const next = queue.shift()!;
_saveQueue(convId);
setTimeout(() => sendMessage(
next.content,
next.contextNoteId,
next.includeNoteIds,
next.think,
next.contextNoteTitle,
next.excludeNoteIds,
next.ragProjectId,
next.workspaceProjectId,
), 0);
}
}
async function reconnectIfGenerating(convId: number): Promise<void> {
@@ -501,6 +588,9 @@ export const useChatStore = defineStore("chat", () => {
defaultModel,
chatReady,
isStreamingConv,
queuedCount,
queuedMessages,
clearQueue,
fetchConversations,
createConversation,
fetchConversation,
+45
View File
@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
getNotificationCount,
getNotifications,
markAllNotificationsRead,
markNotificationRead,
type NotificationEntry,
} from '@/api/client'
export const useNotificationsStore = defineStore('notifications', () => {
const count = ref(0)
const items = ref<NotificationEntry[]>([])
async function fetchCount() {
try {
count.value = await getNotificationCount()
} catch {
// silently ignore — polling failure shouldn't surface to user
}
}
async function fetchAll() {
try {
items.value = await getNotifications(false)
count.value = items.value.length
} catch {
//
}
}
async function markRead(id: number) {
await markNotificationRead(id)
items.value = items.value.filter(n => n.id !== id)
count.value = Math.max(0, count.value - 1)
}
async function markAll() {
await markAllNotificationsRead()
items.value = []
count.value = 0
}
return { count, items, fetchCount, fetchAll, markRead, markAll }
})
+3 -2
View File
@@ -1,11 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
function urlBase64ToUint8Array(base64String: string): Uint8Array {
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
const buf = new ArrayBuffer(rawData.length)
const outputArray = new Uint8Array<ArrayBuffer>(buf)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
+1 -1
View File
@@ -9,7 +9,7 @@ export interface GenerationTiming {
export interface ToolCallRecord {
function: string;
arguments: Record<string, unknown>;
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[] };
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[]; requires_confirmation?: boolean; similar_note?: { id: number; title: string } };
status: "running" | "success" | "error" | "declined";
}
+3 -2
View File
@@ -1,4 +1,4 @@
import { marked } from "marked";
import { marked, type RendererThis, type Tokens } from "marked";
import DOMPurify from "dompurify";
import { linkifyTags, linkifyWikilinks } from "@/utils/tags";
@@ -28,7 +28,8 @@ export function stripFirstLineTags(text: string): string {
}
const headingRenderer = {
heading({ text, depth }: { text: string; depth: number }): string {
heading(this: RendererThis, { tokens, depth }: Tokens.Heading): string {
const text = this.parser.parseInline(tokens);
const id = slugify(text);
return `<h${depth} id="${id}">${text}</h${depth}>`;
},
+395
View File
@@ -0,0 +1,395 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useChatStore } from '@/stores/chat'
import ChatMessage from '@/components/ChatMessage.vue'
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
import {
getBriefingConfig,
getBriefingConversations,
getBriefingToday,
getBriefingConvMessages,
triggerBriefingSlot,
type BriefingConversation,
type BriefingMessage,
} from '@/api/client'
import type { Message } from '@/types/chat'
const chatStore = useChatStore()
// Setup wizard
const showWizard = ref(false)
const wizardChecked = ref(false)
async function checkSetup() {
const config = await getBriefingConfig()
if (!config.enabled) showWizard.value = true
wizardChecked.value = true
}
async function onWizardDone() {
showWizard.value = false
await loadAll()
}
// Conversations list for the dropdown
const conversations = ref<BriefingConversation[]>([])
const selectedConvId = ref<number | null>(null)
const todayConvId = ref<number | null>(null)
const isToday = computed(() => selectedConvId.value === todayConvId.value)
// Messages for selected conversation
const messages = ref<BriefingMessage[]>([])
const loadingMessages = ref(false)
async function loadAll() {
const [convList, today] = await Promise.all([
getBriefingConversations(),
getBriefingToday().catch(() => null),
])
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() },
...convList,
]
}
selectedConvId.value = today.id
messages.value = today.messages
// Load into chatStore so we can stream
await chatStore.fetchConversation(today.id)
}
}
watch(selectedConvId, async (id) => {
if (!id) return
if (id === todayConvId.value) {
await chatStore.fetchConversation(id)
messages.value = (chatStore.currentConversation?.messages ?? []) as unknown as BriefingMessage[]
return
}
loadingMessages.value = true
try {
messages.value = await getBriefingConvMessages(id)
} finally {
loadingMessages.value = false
}
})
// Refresh messages after streaming ends
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
}
})
// Input
const input = ref('')
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)
}
input.value = ''
sending.value = true
try {
await chatStore.sendMessage(text)
} finally {
sending.value = false
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
// Manual trigger
const triggering = ref(false)
async function triggerNow() {
triggering.value = true
try {
await triggerBriefingSlot('compilation')
// Reload
await loadAll()
} finally {
triggering.value = false
}
}
// Dropdown label
function convLabel(c: BriefingConversation): string {
if (c.id === todayConvId.value) return 'Today'
if (c.briefing_date) {
const d = new Date(c.briefing_date)
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
return c.title || 'Briefing'
}
// Convert BriefingMessage to Message for ChatMessage component
function toMsg(m: BriefingMessage): Message {
return {
id: m.id,
conversation_id: -1,
role: m.role,
content: m.content,
context_note_id: null,
context_note_title: null,
created_at: m.created_at,
}
}
onMounted(async () => {
await checkSetup()
if (!showWizard.value) await loadAll()
})
</script>
<template>
<div class="briefing-root">
<!-- Setup wizard overlay -->
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
<!-- Main view (shown after wizard check and only if not showing wizard) -->
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
<!-- Header -->
<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>
<button
class="btn-trigger"
@click="triggerNow"
:disabled="triggering"
title="Manually trigger morning briefing now"
>{{ triggering ? '…' : 'Refresh' }}</button>
</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>
</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>
</div>
</template>
<style scoped>
.briefing-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.briefing-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
max-width: 760px;
margin: 0 auto;
width: 100%;
padding: 0 1rem;
}
.briefing-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 0 1rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
gap: 1rem;
flex-wrap: wrap;
}
.briefing-header-left {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.briefing-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.3rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.briefing-today-badge {
font-size: 0.82rem;
color: var(--color-text-muted);
}
.briefing-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.briefing-conv-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;
}
.btn-trigger {
padding: 0.35rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
font-family: inherit;
}
.btn-trigger:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
.briefing-messages-wrap {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
}
.briefing-loading,
.briefing-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.briefing-empty-hint {
font-size: 0.8rem;
opacity: 0.7;
margin-top: 0.5rem;
}
.briefing-messages {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.briefing-input-bar {
display: flex;
gap: 0.5rem;
align-items: flex-end;
padding: 0.75rem 0 1rem;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.briefing-input {
flex: 1;
padding: 0.6rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
resize: none;
outline: none;
font-family: inherit;
line-height: 1.4;
max-height: 120px;
overflow-y: auto;
transition: border-color 0.15s;
}
.briefing-input:focus { border-color: var(--color-primary); }
.btn-send {
padding: 0.55rem 0.75rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s;
flex-shrink: 0;
}
.btn-send:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-send:hover:not(:disabled) { opacity: 0.9; }
</style>
+95 -18
View File
@@ -114,7 +114,8 @@ const streamingRendered = computed(() => {
const inputPlaceholder = computed(() => {
if (!store.chatReady) return "Chat unavailable";
return "Type a message... (Enter to send, Shift+Enter for new line)";
if (store.streaming) return "Type to queue next message (Enter to queue)";
return "Type a message… (Enter to send, Shift+Enter for new line)";
});
onMounted(async () => {
@@ -283,7 +284,7 @@ async function removeConversation(id: number) {
async function sendMessage() {
const content = messageInput.value.trim();
if (!content || store.streaming) return;
if (!content) return;
// Auto-create conversation if none selected
if (!store.currentConversation) {
@@ -475,6 +476,13 @@ function onGlobalKeydown(e: KeyboardEvent) {
onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
// Clean up empty conversation when navigating away from the chat view entirely
if (prevConvId) {
const conv = store.conversations.find((c) => c.id === prevConvId);
if (conv && conv.message_count === 0) {
store.deleteConversation(prevConvId);
}
}
});
</script>
@@ -631,6 +639,25 @@ onUnmounted(() => {
</div>
</div>
<!-- Queued messages shown as pending bubbles -->
<template v-if="store.queuedMessages.length">
<div
v-for="(q, i) in store.queuedMessages"
:key="`queued-${i}`"
class="chat-message role-user queued-message"
>
<div class="message-bubble queued-bubble">
<div class="queued-badge">Queued</div>
<div class="message-content">{{ q.content }}</div>
</div>
</div>
<div class="queued-clear-row">
<button class="queued-clear-btn" @click="store.clearQueue()" aria-label="Cancel queued messages">
Cancel {{ store.queuedMessages.length }} queued
</button>
</div>
</template>
<p
v-if="!store.currentConversation.messages.length && !store.streaming"
class="empty-msg"
@@ -775,7 +802,7 @@ onUnmounted(() => {
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="inputPlaceholder"
:disabled="store.streaming || !store.chatReady"
:disabled="!store.chatReady"
rows="1"
></textarea>
<button
@@ -827,7 +854,6 @@ onUnmounted(() => {
.chat-sidebar {
width: 260px;
min-width: 200px;
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
@@ -848,9 +874,11 @@ onUnmounted(() => {
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-new-conv:hover {
opacity: 0.9;
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
}
.btn-select-mode {
@@ -971,13 +999,17 @@ onUnmounted(() => {
border-radius: var(--radius-sm);
cursor: pointer;
margin-bottom: 0.25rem;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.conv-item:hover {
background: var(--color-bg-card);
background: rgba(99,102,241,0.05);
border-left: 3px solid var(--color-primary);
}
.conv-item.active {
background: var(--color-primary);
color: #fff;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
border-left: 3px solid var(--color-primary);
}
.conv-info {
flex: 1;
@@ -997,7 +1029,7 @@ onUnmounted(() => {
margin-top: 1px;
}
.conv-item.active .conv-date {
color: rgba(255, 255, 255, 0.7);
color: var(--color-text-muted);
}
.btn-delete-conv {
background: none;
@@ -1009,7 +1041,7 @@ onUnmounted(() => {
line-height: 1;
}
.conv-item.active .btn-delete-conv {
color: rgba(255, 255, 255, 0.7);
color: var(--color-text-muted);
}
.btn-delete-conv:hover {
color: var(--color-danger, #e74c3c);
@@ -1027,7 +1059,6 @@ onUnmounted(() => {
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.chat-header h2 {
margin: 0;
@@ -1096,7 +1127,6 @@ onUnmounted(() => {
.context-sidebar {
width: 220px;
min-width: 180px;
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
display: flex;
flex-direction: column;
@@ -1200,10 +1230,11 @@ onUnmounted(() => {
.streaming-bubble {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 16px;
border-radius: 18px;
border-bottom-left-radius: 4px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-left: 2px solid var(--color-primary);
box-shadow: var(--color-bubble-asst-shadow);
}
.streaming-bubble .message-header {
display: flex;
@@ -1213,7 +1244,7 @@ onUnmounted(() => {
.streaming-bubble .role-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
@@ -1240,8 +1271,8 @@ onUnmounted(() => {
}
.thinking-block {
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
border-left: 2px solid rgba(129, 140, 248, 0.35);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
overflow: hidden;
}
.thinking-summary {
@@ -1279,7 +1310,6 @@ details[open] .thinking-summary::before {
max-height: 300px;
overflow-y: auto;
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
}
.streaming-status-dot {
display: inline-block;
@@ -1497,13 +1527,17 @@ details[open] .thinking-summary::before {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 1.1rem;
flex-shrink: 0;
transition: box-shadow 0.15s;
}
.btn-send:not(:disabled):hover {
box-shadow: 0 0 14px rgba(129, 140, 248, 0.5);
}
.btn-send:disabled {
opacity: 0.35;
@@ -1601,4 +1635,47 @@ details[open] .thinking-summary::before {
margin-bottom: 0.5rem;
}
}
.chat-message.role-user {
justify-content: flex-end;
}
.queued-bubble {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 18px;
border-bottom-right-radius: 4px;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
opacity: 0.45;
font-size: 0.95rem;
line-height: 1.55;
word-break: break-word;
}
.queued-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 0.2rem;
}
.queued-clear-row {
display: flex;
justify-content: flex-end;
padding-right: 0.5rem;
}
.queued-clear-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.78rem;
padding: 0.2rem 0.6rem;
font-family: inherit;
}
.queued-clear-btn:hover {
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
</style>
+16 -2
View File
@@ -258,7 +258,7 @@ function initGraph() {
openPeek(d);
}
})
.on("mouseover", (event: MouseEvent, d: GraphNode) => {
.on("mouseover", function(event: MouseEvent, d: GraphNode) {
const rect = containerRef.value!.getBoundingClientRect();
tooltip.value = {
visible: true,
@@ -266,14 +266,28 @@ function initGraph() {
y: event.clientY - rect.top - 8,
node: d,
};
select(this as SVGGElement).select("circle")
.transition().duration(150)
.attr("r", (d.radius ?? 8) * 1.3);
select(this as SVGGElement).select("text")
.transition().duration(150)
.style("font-weight", "600")
.style("font-size", "11px");
})
.on("mousemove", (event: MouseEvent) => {
const rect = containerRef.value!.getBoundingClientRect();
tooltip.value.x = event.clientX - rect.left + 12;
tooltip.value.y = event.clientY - rect.top - 8;
})
.on("mouseout", () => {
.on("mouseout", function(_event: MouseEvent, d: GraphNode) {
tooltip.value.visible = false;
select(this as SVGGElement).select("circle")
.transition().duration(150)
.attr("r", d.radius ?? 8);
select(this as SVGGElement).select("text")
.transition().duration(150)
.style("font-weight", null)
.style("font-size", null);
});
nodeSel
File diff suppressed because it is too large Load Diff
+16 -32
View File
@@ -18,7 +18,7 @@ import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
import DiffView from "@/components/DiffView.vue";
import HistoryPanel from "@/components/HistoryPanel.vue";
import VersionHistorySection from "@/components/VersionHistorySection.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const route = useRoute();
@@ -35,7 +35,6 @@ const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const sidebarOpen = ref(true);
const showHistory = ref(false);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
@@ -321,12 +320,11 @@ onUnmounted(() => assist.clearSelection());
<main class="editor-page note-editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link to="/notes" class="btn-back">Back</router-link>
<router-link to="/notes" class="btn-back"> Notes</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
<button v-if="isEditing" class="btn-history" @click="showHistory = true">History</button>
<WordCount :body="body" />
</div>
<input
@@ -513,6 +511,13 @@ onUnmounted(() => assist.clearSelection());
</template>
</div>
<VersionHistorySection
v-if="noteId"
:note-id="noteId"
:current-body="body"
@restore="(b, t) => { body = b; tags = t; markDirty(); }"
/>
</div><!-- /sidebar-content -->
</aside>
</div><!-- /note-body -->
@@ -527,15 +532,6 @@ onUnmounted(() => assist.clearSelection());
> Assist</button>
</teleport>
<!-- History panel -->
<HistoryPanel
v-if="showHistory && noteId"
:note-id="noteId"
:current-body="body"
@restore="(b: string, t: string[]) => { body = b; tags = t; markDirty(); }"
@close="showHistory = false"
/>
<!-- Delete confirmation -->
<ConfirmDialog
v-if="showDeleteConfirm"
@@ -575,8 +571,12 @@ onUnmounted(() => assist.clearSelection());
.body-tabs-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.editor-tabs {
@@ -586,7 +586,7 @@ onUnmounted(() => assist.clearSelection());
border-radius: 8px;
padding: 2px;
gap: 2px;
align-self: flex-start;
flex-shrink: 0;
}
.tab {
@@ -734,22 +734,6 @@ onUnmounted(() => assist.clearSelection());
letter-spacing: 0.05em;
}
/* History button */
.btn-history {
padding: 0.45rem 1rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-secondary);
font-family: inherit;
}
.btn-history:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Narrow screen: sidebar collapses */
@media (max-width: 720px) {
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
+207 -50
View File
@@ -8,13 +8,14 @@ import { apiPost, apiGet } from "@/api/client";
import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
import ShareDialog from "@/components/ShareDialog.vue";
const route = useRoute();
const router = useRouter();
const store = useNotesStore();
const bodyEl = ref<HTMLElement | null>(null);
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const converting = ref(false);
const showShare = ref(false);
// Context enrichment
const projectTitle = ref<string | null>(null);
@@ -139,7 +140,20 @@ async function convertToTask() {
<template>
<div class="viewer-layout">
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<div v-if="store.loading" class="viewer-skeleton" aria-label="Loading note">
<div class="skel-toolbar">
<div class="skel-btn"></div>
<div class="skel-btn skel-btn--wide"></div>
</div>
<div class="skel-title"></div>
<div class="skel-meta"></div>
<div class="skel-line"></div>
<div class="skel-line skel-line--short"></div>
<div class="skel-line"></div>
<div class="skel-line skel-line--medium"></div>
<div class="skel-line"></div>
<div class="skel-line skel-line--short"></div>
</div>
<template v-else-if="store.currentNote">
<div class="toolbar">
<router-link to="/notes" class="btn-back"> Notes</router-link>
@@ -157,6 +171,7 @@ async function convertToTask() {
>
{{ converting ? "Converting..." : "Convert to Task" }}
</button>
<button class="btn-share" @click="showShare = true">Share</button>
</div>
<!-- Breadcrumb: parent project milestone -->
@@ -183,11 +198,17 @@ async function convertToTask() {
</span>
</div>
<h1>{{ store.currentNote.title || "Untitled" }}</h1>
<h1 class="note-title">{{ store.currentNote.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentNote.updated_at) }}
&middot;
Created {{ relativeTime(store.currentNote.created_at) }}
<span class="meta-item">
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
Updated {{ relativeTime(store.currentNote.updated_at) }}
</span>
<span class="meta-sep" aria-hidden="true">·</span>
<span class="meta-item">
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
Created {{ relativeTime(store.currentNote.created_at) }}
</span>
</p>
<div class="tags" v-if="store.currentNote.tags.length">
<TagPill
@@ -198,25 +219,28 @@ async function convertToTask() {
/>
</div>
<div
ref="bodyEl"
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
<h3 class="backlinks-heading">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
Backlinks
<span class="backlinks-count">{{ backlinks.length }}</span>
</h3>
<div class="backlinks-grid">
<router-link
v-for="link in backlinks"
:key="`${link.type}-${link.id}`"
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-card"
>
<span :class="['backlink-type-badge', `badge-${link.type}`]">{{ link.type }}</span>
<span class="backlink-title">{{ link.title || "Untitled" }}</span>
</router-link>
</div>
</div>
</template>
<p v-else>Note not found.</p>
@@ -227,6 +251,14 @@ async function convertToTask() {
class="toc-sidebar"
/>
</div>
<ShareDialog
v-if="showShare && store.currentNote"
resource-type="note"
:resource-id="store.currentNote.id"
:resource-title="store.currentNote.title || '(untitled)'"
@close="showShare = false"
/>
</template>
<style src="@/assets/viewer-shared.css" />
@@ -257,8 +289,7 @@ async function convertToTask() {
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.btn-back,
.btn-edit {
.btn-back {
display: inline-flex;
align-items: center;
padding: 0.45rem 1rem;
@@ -270,11 +301,30 @@ async function convertToTask() {
cursor: pointer;
font-size: 0.9rem;
}
.btn-back:hover,
.btn-edit:hover {
.btn-back:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-edit {
display: inline-flex;
align-items: center;
padding: 0.45rem 1.1rem;
border: none;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-edit:hover {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
opacity: 0.95;
color: #fff;
}
.btn-convert {
margin-left: auto;
padding: 0.3rem 0.75rem;
@@ -295,11 +345,45 @@ async function convertToTask() {
cursor: default;
}
.meta {
.btn-share {
padding: 0.3rem 0.75rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
}
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
.note-title {
font-family: "Fraunces", Georgia, serif;
font-size: 2rem;
font-weight: 700;
line-height: 1.2;
margin: 0.25rem 0 0.5rem;
color: var(--color-text);
}
.meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.83rem;
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.meta-sep {
opacity: 0.5;
}
.tags {
display: flex;
gap: 0.5rem;
@@ -307,41 +391,114 @@ async function convertToTask() {
flex-wrap: wrap;
}
.backlinks {
margin-top: 2rem;
margin-top: 2.5rem;
border-top: 1px solid var(--color-border);
padding-top: 1rem;
padding-top: 1.25rem;
}
.backlinks h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
}
.backlinks-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.backlink-item {
.backlinks-heading {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.backlink-link {
color: var(--color-text);
.backlinks-count {
margin-left: 0.2rem;
font-size: 0.72rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0 0.4rem;
line-height: 1.4;
}
.backlinks-grid {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.backlink-card {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
text-decoration: none;
font-size: 0.95rem;
color: var(--color-text);
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.9rem;
}
.backlink-link:hover {
.backlink-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
color: var(--color-primary);
}
.backlink-type {
font-size: 0.75rem;
.backlink-type-badge {
font-size: 0.68rem;
text-transform: uppercase;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 0.1rem 0.4rem;
border-radius: var(--radius-sm);
letter-spacing: 0.04em;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
}
.badge-note {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.badge-task {
background: color-mix(in srgb, #f59e0b 12%, transparent);
color: #d97706;
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
}
.backlink-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Skeleton loader ── */
@keyframes skel-shine {
to { background-position: 200% center; }
}
.viewer-skeleton {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding-top: 0.5rem;
}
.skel-btn,
.skel-title,
.skel-meta,
.skel-line {
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-secondary) 25%,
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
var(--color-bg-secondary) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
}
.skel-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.skel-btn { width: 70px; height: 32px; }
.skel-btn--wide { width: 90px; }
.skel-title { height: 2.2rem; width: 70%; border-radius: var(--radius-md); }
.skel-meta { height: 0.85rem; width: 40%; }
.skel-line { height: 0.9rem; }
.skel-line--short { width: 55%; }
.skel-line--medium { width: 80%; }
</style>
+54 -4
View File
@@ -139,7 +139,14 @@ function onOffsetUpdate(offset: number) {
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
</div>
<p v-if="store.loading">Loading...</p>
<div v-if="store.loading" class="note-list-skeleton">
<div v-if="viewMode === 'grid'" class="skeleton-grid">
<div class="skeleton-card" v-for="i in 6" :key="i"></div>
</div>
<div v-else class="skeleton-rows">
<div class="skeleton-row" v-for="i in 8" :key="i"></div>
</div>
</div>
<div v-else-if="store.notes.length === 0" class="empty-state">
<template v-if="store.searchQuery || store.activeTagFilters.length">
<p class="empty-title">No notes match your filters</p>
@@ -201,11 +208,16 @@ function onOffsetUpdate(offset: number) {
}
.btn-new {
padding: 0.45rem 1rem;
background: var(--color-primary);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
}
.btn-new:hover {
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
filter: brightness(1.08);
}
.controls {
display: flex;
@@ -275,10 +287,43 @@ function onOffsetUpdate(offset: number) {
padding: 0;
}
/* Skeleton loading */
.note-list-skeleton {
margin-top: 1rem;
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.75rem;
}
.skeleton-card {
height: 140px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.skeleton-row {
height: 40px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Grid layout */
.cards-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.75rem;
margin-top: 1rem;
}
@@ -312,11 +357,16 @@ function onOffsetUpdate(offset: number) {
.btn-cta {
display: inline-block;
padding: 0.45rem 1rem;
background: var(--color-primary);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
}
.btn-cta:hover {
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
filter: brightness(1.08);
}
.kb-active-item {
+29 -29
View File
@@ -150,13 +150,16 @@ function truncate(text: string | null, max = 120): string {
</button>
</div>
<p v-if="loading" class="loading-msg">Loading...</p>
<div v-if="loading" class="skeleton-grid">
<div class="skeleton-card" v-for="i in 4" :key="i"></div>
</div>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<div v-else-if="filteredProjects.length === 0" class="empty-state">
<div v-else-if="filteredProjects.length === 0" class="empty-state-rich">
<div class="empty-icon"></div>
<p class="empty-title">No projects yet</p>
<p class="empty-subtitle">Create your first project to organize tasks and notes.</p>
<button class="btn-cta" @click="openNewProjectModal">+ New Project</button>
<p class="empty-sub">Organise your notes and tasks into a project</p>
<button class="empty-action" @click="openNewProjectModal">New project </button>
</div>
<div v-else class="projects-grid">
@@ -327,32 +330,28 @@ function truncate(text: string | null, max = 120): string {
color: var(--color-danger);
}
.empty-state {
text-align: center;
margin-top: 3rem;
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--color-text-muted); }
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
.empty-title { font-size: 1rem; font-weight: 600; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-primary); border-radius: var(--radius-sm); color: var(--color-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
.empty-action:hover { background: var(--color-primary); color: #fff; }
.skeleton-card {
height: 140px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.projects-grid {
@@ -367,7 +366,7 @@ function truncate(text: string | null, max = 120): string {
border-radius: var(--radius-md);
padding: 1rem 1.1rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
display: flex;
flex-direction: column;
gap: 0.5rem;
@@ -375,6 +374,7 @@ function truncate(text: string | null, max = 120): string {
.project-card:hover {
border-color: var(--color-primary);
box-shadow: 0 2px 8px var(--color-shadow);
transform: translateY(-2px);
}
.card-header {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getSharedWithMe } from '@/api/client'
interface SharedProject {
id: number
title: string
description: string | null
status: string
color: string | null
permission: string
owner_username: string
}
interface SharedNote {
id: number
title: string
is_task: boolean
permission: string
owner_username: string
}
const projects = ref<SharedProject[]>([])
const notes = ref<SharedNote[]>([])
const loading = ref(true)
onMounted(async () => {
try {
const data = await getSharedWithMe()
projects.value = data.projects as unknown as SharedProject[]
notes.value = data.notes as unknown as SharedNote[]
} finally {
loading.value = false
}
})
</script>
<template>
<div class="shared-page page-container">
<header class="page-header">
<h1 class="page-title">Shared with me</h1>
</header>
<div v-if="loading" class="loading-state">Loading</div>
<template v-else>
<!-- Projects -->
<section class="shared-section">
<h2 class="section-heading">Projects</h2>
<div v-if="projects.length" class="shared-grid">
<router-link
v-for="p in projects"
:key="p.id"
:to="`/projects/${p.id}`"
class="shared-card"
>
<div class="card-color-bar" :style="p.color ? `background: ${p.color}` : ''" />
<div class="card-body">
<div class="card-title">{{ p.title }}</div>
<div class="card-meta">
<span class="card-owner">by {{ p.owner_username }}</span>
<span class="perm-badge" :class="`perm-${p.permission}`">{{ p.permission }}</span>
</div>
<p v-if="p.description" class="card-desc">{{ p.description }}</p>
</div>
</router-link>
</div>
<p v-else class="empty-msg">No projects shared with you yet.</p>
</section>
<!-- Notes & Tasks -->
<section class="shared-section">
<h2 class="section-heading">Notes & Tasks</h2>
<div v-if="notes.length" class="shared-list">
<router-link
v-for="n in notes"
:key="n.id"
:to="n.is_task ? `/tasks/${n.id}` : `/notes/${n.id}`"
class="shared-row"
>
<span class="row-icon">{{ n.is_task ? '✓' : '📄' }}</span>
<span class="row-title">{{ n.title || '(untitled)' }}</span>
<span class="row-owner">by {{ n.owner_username }}</span>
<span class="perm-badge" :class="`perm-${n.permission}`">{{ n.permission }}</span>
</router-link>
</div>
<p v-else class="empty-msg">No notes or tasks shared with you yet.</p>
</section>
</template>
</div>
</template>
<style scoped>
.shared-page {
padding: 2rem;
max-width: 900px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.8rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.loading-state {
color: var(--color-muted);
padding: 2rem;
text-align: center;
}
.shared-section {
margin-bottom: 2.5rem;
}
.section-heading {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-muted);
margin: 0 0 0.75rem;
}
.shared-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.shared-card {
display: flex;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
text-decoration: none;
transition: box-shadow 0.15s, transform 0.15s;
}
.shared-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.card-color-bar {
width: 4px;
flex-shrink: 0;
background: var(--color-border);
}
.card-body {
padding: 0.9rem 1rem;
flex: 1;
min-width: 0;
}
.card-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--color-text);
margin-bottom: 0.3rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.card-owner {
font-size: 0.78rem;
color: var(--color-muted);
}
.card-desc {
font-size: 0.82rem;
color: var(--color-muted);
margin: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.shared-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.shared-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
text-decoration: none;
transition: background 0.1s;
}
.shared-row:hover {
background: var(--color-hover);
}
.row-icon {
font-size: 0.9rem;
flex-shrink: 0;
color: var(--color-muted);
}
.row-title {
flex: 1;
font-size: 0.9rem;
color: var(--color-text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-owner {
font-size: 0.78rem;
color: var(--color-muted);
white-space: nowrap;
}
.perm-badge {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 4px;
white-space: nowrap;
}
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
.perm-admin { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
.empty-msg {
color: var(--color-muted);
font-style: italic;
font-size: 0.88rem;
margin: 0;
padding: 1rem 0;
}
</style>
+155 -96
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from "vue";
import { ref, onMounted, computed, nextTick, onUnmounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
@@ -20,8 +20,9 @@ import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
import TaskLogSection from "@/components/TaskLogSection.vue";
import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
import DiffView from "@/components/DiffView.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import VersionHistorySection from "@/components/VersionHistorySection.vue";
const route = useRoute();
const router = useRouter();
@@ -98,7 +99,7 @@ async function toggleSubTask(sub: SubTask) {
toast.show("Failed to update sub-task", "error");
}
}
const showPreview = ref(true);
const showPreview = ref(false);
const sidebarOpen = ref(true);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null);
@@ -116,46 +117,62 @@ const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist
const assist = useAssist(body, taskId, projectId);
const assistLabel = computed(() => {
if (assist.isProofreading.value) return "Proofreading document...";
const t = assist.target.value;
if (!t) return "Generating...";
const heading = t.text.split("\n")[0];
return `Revising: "${heading.length > 50 ? heading.slice(0, 50) + "..." : heading}"`;
// Scope selector — matches note editor pattern
const scopeOptions = computed(() => {
const opts: Array<{ value: string; label: string }> = [
{ value: "__document__", label: "Whole document" },
];
for (const section of assist.sections.value) {
opts.push({
value: String(assist.sections.value.indexOf(section)),
label: section.heading || "(preamble)",
});
}
return opts;
});
// Assist panel toggle (persisted)
const ASSIST_KEY = 'fa-assist-open';
const assistOpen = ref(localStorage.getItem(ASSIST_KEY) !== 'false');
function toggleAssist() {
assistOpen.value = !assistOpen.value;
localStorage.setItem(ASSIST_KEY, String(assistOpen.value));
}
const scopeSelectValue = computed({
get() {
if (assist.scopeMode.value === "document") return "__document__";
if (assist.selectedSection.value) {
const idx = assist.sections.value.indexOf(assist.selectedSection.value);
return idx >= 0 ? String(idx) : "__document__";
}
return "__document__";
},
set(val: string) {
if (val === "__document__") {
assist.scopeMode.value = "document";
assist.selectedSection.value = null;
} else {
const idx = Number(val);
const section = assist.sections.value[idx];
if (section) {
assist.scopeMode.value = "section";
assist.selectSection(section);
}
}
},
});
// Floating inline assist button
const instructionRef = ref<HTMLTextAreaElement | null>(null);
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
({ start, end }) => {
assist.scopeMode.value = "section";
assist.selectTextRange(start, end);
assistOpen.value = true;
localStorage.setItem(ASSIST_KEY, 'true');
nextTick(() => instructionRef.value?.focus());
}
);
function handleAssistAccept() {
const newBody = assist.accept();
if (newBody !== body.value) {
body.value = newBody;
markDirty();
toast.show("Section updated");
}
body.value = newBody;
markDirty();
toast.show("Task updated");
}
function truncateTarget(text: string, max = 60): string {
const first = text.split("\n")[0];
return first.length > max ? first.slice(0, max) + "..." : first;
}
onUnmounted(() => assist.clearSelection());
// Tag suggestions
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
@@ -266,6 +283,8 @@ onMounted(async () => {
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
// Start in preview mode only if the task already has body content
showPreview.value = body.value.trim().length > 0;
}
loadSubTasks();
} else {
@@ -304,6 +323,7 @@ async function save() {
savedParentId = parentId.value;
dirty.value = false;
toast.show("Task saved");
window.location.reload();
} else {
const task = await store.createTask(data);
dirty.value = false;
@@ -379,16 +399,11 @@ useEditorGuards(dirty, save);
<main class="editor-page task-editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<router-link to="/tasks" class="btn-back"> Tasks</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
Delete
</button>
<button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist">
Assist
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
<WordCount :body="body" />
</div>
<input
@@ -416,38 +431,36 @@ useEditorGuards(dirty, save);
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
</div>
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
</div>
<!-- Inline assist output -->
<InlineAssistPanel
v-if="assist.state.value !== 'idle'"
:phase="assist.state.value as 'streaming' | 'review'"
:label="assistLabel"
:streaming-text="assist.streamingText.value"
:diff="assist.diff.value"
:proposed-text="assist.proposedText.value"
@accept="handleAssistAccept"
@reject="assist.reject()"
@cancel="assist.clearSelection()"
/>
<!-- Streaming preview -->
<template v-if="assist.state.value === 'streaming'">
<div class="stream-label">Generating...</div>
<div class="stream-preview prose" v-html="renderMarkdown(assist.streamingText.value)" />
</template>
<!-- Editor / preview (hidden during review) -->
<div v-show="!showPreview && assist.state.value !== 'review'" class="body-editor-wrap">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Describe this task..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/>
</div>
<div
v-show="showPreview && assist.state.value !== 'review'"
class="preview-pane prose"
v-html="renderedPreview"
></div>
<!-- Review: full-document diff -->
<template v-else-if="assist.state.value === 'review'">
<DiffView :diff="assist.diff.value" class="main-diff" />
</template>
<!-- Normal: editor or preview -->
<template v-else>
<div v-show="!showPreview" class="body-editor-wrap">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Describe this task..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/>
</div>
<div v-show="showPreview" class="preview-pane prose" v-html="renderedPreview" />
</template>
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
<!-- Work log -->
<TaskLogSection v-if="taskId" :task-id="taskId" class="body-log" />
@@ -585,51 +598,57 @@ useEditorGuards(dirty, save);
</template>
</div>
</div><!-- /sidebar-content -->
</aside><!-- /task-sidebar -->
<div class="sb-divider"></div>
<!-- Assist panel overlays on top of sidebar when open -->
<aside v-if="assistOpen" class="assist-panel">
<div class="assist-panel-header">
<span class="assist-panel-title"> AI Assist</span>
<button class="btn-proofread" @click="assist.proofread()" :disabled="assist.state.value === 'streaming'">Proofread</button>
<button class="btn-close-assist" aria-label="Close assist panel" @click="toggleAssist">×</button>
</div>
<div class="assist-panel-body">
<div v-if="assist.state.value === 'idle'" class="assist-idle">
<div class="assist-sections-label">Sections</div>
<div class="assist-sections">
<div
v-for="(section, i) in assist.sections.value"
:key="i"
:class="['assist-section-item', { selected: assist.selectedSection.value === section }]"
@click="assist.selectSection(section)"
>{{ section.heading || '(preamble)' }}</div>
<div v-if="!assist.sections.value.length" class="assist-empty">Write some content to get started.</div>
<!-- Writing Assistant -->
<div class="assist-section">
<div class="assist-section-title"> Writing Assistant</div>
<div class="sb-field">
<label class="sb-label">Scope</label>
<select v-model="scopeSelectValue" class="sb-select" :disabled="assist.state.value === 'streaming'">
<option v-for="opt in scopeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<template v-if="assist.target.value">
<div class="assist-target-preview">Editing: <em>{{ truncateTarget(assist.target.value.text) }}</em></div>
<template v-if="assist.state.value === 'idle'">
<textarea
ref="instructionRef"
v-model="assist.instruction.value"
placeholder="What should I do with this section?"
placeholder="What should I do?"
class="assist-instruction"
rows="3"
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
></textarea>
<div class="assist-input-actions">
<button class="btn-generate" @click="assist.submit()" :disabled="!assist.canSubmit.value">Generate</button>
<button class="btn-clear" @click="assist.clearSelection()">Clear</button>
<button class="btn-proofread" @click="assist.proofread()">Proofread</button>
</div>
</template>
<template v-else-if="assist.state.value === 'streaming'">
<div class="assist-active-hint">Generating see main area</div>
<button class="btn-clear" @click="assist.clearSelection()">Cancel</button>
</template>
<template v-else-if="assist.state.value === 'review'">
<div class="assist-active-hint">Review the diff in the main area</div>
<div class="assist-actions">
<button class="btn-accept" @click="handleAssistAccept">Accept</button>
<button class="btn-reject" @click="assist.reject()">Reject</button>
</div>
</template>
<div v-else class="assist-hint">Select a section above or highlight text in the editor.</div>
</div>
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
<div v-if="assist.state.value !== 'idle'" class="assist-active-hint">
{{ assist.state.value === 'streaming' ? 'Generating in editor' : 'Review diff in editor ' }}
</div>
</div>
</aside>
<VersionHistorySection
v-if="taskId"
:note-id="taskId"
:current-body="body"
@restore="(b, t) => { body = b; tags = t; markDirty(); }"
/>
</div><!-- /sidebar-content -->
</aside><!-- /task-sidebar -->
</div><!-- /task-body -->
@@ -684,8 +703,12 @@ useEditorGuards(dirty, save);
.body-tabs-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.body-editor-wrap {
@@ -836,6 +859,42 @@ useEditorGuards(dirty, save);
font-family: inherit;
}
/* Streaming preview */
.stream-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
.stream-preview {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
padding: 0.75rem;
background: var(--color-bg-card);
min-height: 200px;
}
.main-diff {
flex: 1;
min-height: 0;
}
/* Writing Assistant section (in sidebar) */
.assist-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.assist-section-title {
font-size: 0.78rem;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.assist-actions {
display: flex;
gap: 0.4rem;
}
/* Tag suggest row inside sidebar */
.tag-suggest-row {
display: flex;
+251 -48
View File
@@ -12,6 +12,7 @@ import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
import ShareDialog from "@/components/ShareDialog.vue";
const route = useRoute();
const router = useRouter();
@@ -19,6 +20,7 @@ const store = useTasksStore();
const notesStore = useNotesStore();
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const converting = ref(false);
const showShare = ref(false);
// Context enrichment
const projectTitle = ref<string | null>(null);
@@ -111,6 +113,26 @@ function cycleStatus() {
);
}
const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
todo: "in_progress",
in_progress: "done",
done: null,
};
const advanceLabel = computed(() => {
const s = store.currentTask?.status as TaskStatus | undefined;
if (!s) return null;
const next = forwardStatus[s];
if (!next) return null;
return next === "in_progress" ? "→ In Progress" : "→ Done";
});
function advanceStatus() {
if (!store.currentTask) return;
const next = forwardStatus[store.currentTask.status as TaskStatus];
if (next) store.patchStatus(store.currentTask.id, next);
}
function isOverdue(): boolean {
if (!store.currentTask?.due_date || store.currentTask.status === "done")
return false;
@@ -182,7 +204,21 @@ const subTaskProgress = computed(() => {
<template>
<div class="viewer-layout">
<main class="viewer">
<p v-if="store.loading">Loading...</p>
<div v-if="store.loading" class="viewer-skeleton" aria-label="Loading task">
<div class="skel-toolbar">
<div class="skel-btn"></div>
<div class="skel-btn skel-btn--wide"></div>
<div class="skel-btn"></div>
</div>
<div class="skel-title"></div>
<div class="skel-meta"></div>
<div class="skel-badges"></div>
<div class="skel-line"></div>
<div class="skel-line skel-line--short"></div>
<div class="skel-line"></div>
<div class="skel-line skel-line--medium"></div>
<div class="skel-line skel-line--short"></div>
</div>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link to="/tasks" class="btn-back"> Tasks</router-link>
@@ -192,6 +228,13 @@ const subTaskProgress = computed(() => {
>
Edit
</router-link>
<button
v-if="advanceLabel"
class="btn-advance"
@click="advanceStatus"
>
{{ advanceLabel }}
</button>
<button
class="btn-convert"
@click="convertToNote"
@@ -199,6 +242,7 @@ const subTaskProgress = computed(() => {
>
{{ converting ? "Converting..." : "Convert to Note" }}
</button>
<button class="btn-share" @click="showShare = true">Share</button>
</div>
<!-- Breadcrumb: parent task project milestone -->
@@ -225,11 +269,17 @@ const subTaskProgress = computed(() => {
</span>
</div>
<h1>{{ store.currentTask.title || "Untitled" }}</h1>
<h1 class="task-title">{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
Updated {{ relativeTime(store.currentTask.updated_at) }}
&middot;
Created {{ relativeTime(store.currentTask.created_at) }}
<span class="meta-item">
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
Updated {{ relativeTime(store.currentTask.updated_at) }}
</span>
<span class="meta-sep" aria-hidden="true">·</span>
<span class="meta-item">
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
Created {{ relativeTime(store.currentTask.created_at) }}
</span>
</p>
<div class="badges">
<StatusBadge
@@ -291,18 +341,22 @@ const subTaskProgress = computed(() => {
</div>
<div v-if="backlinks.length" class="backlinks">
<h2>Backlinks</h2>
<ul class="backlinks-list">
<li v-for="link in backlinks" :key="`${link.type}-${link.id}`" class="backlink-item">
<span class="backlink-type">{{ link.type }}</span>
<router-link
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-link"
>
{{ link.title || "Untitled" }}
</router-link>
</li>
</ul>
<h3 class="backlinks-heading">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
Backlinks
<span class="backlinks-count">{{ backlinks.length }}</span>
</h3>
<div class="backlinks-grid">
<router-link
v-for="link in backlinks"
:key="`${link.type}-${link.id}`"
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-card"
>
<span :class="['backlink-type-badge', `badge-${link.type}`]">{{ link.type }}</span>
<span class="backlink-title">{{ link.title || "Untitled" }}</span>
</router-link>
</div>
</div>
</template>
<p v-else>Task not found.</p>
@@ -313,6 +367,14 @@ const subTaskProgress = computed(() => {
class="toc-sidebar"
/>
</div>
<ShareDialog
v-if="showShare && store.currentTask"
resource-type="note"
:resource-id="store.currentTask.id"
:resource-title="store.currentTask.title || '(untitled)'"
@close="showShare = false"
/>
</template>
<style src="@/assets/viewer-shared.css" />
@@ -343,8 +405,7 @@ const subTaskProgress = computed(() => {
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.btn-back,
.btn-edit {
.btn-back {
display: inline-flex;
align-items: center;
padding: 0.45rem 1rem;
@@ -356,11 +417,44 @@ const subTaskProgress = computed(() => {
cursor: pointer;
font-size: 0.9rem;
}
.btn-back:hover,
.btn-edit:hover {
.btn-back:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-edit {
display: inline-flex;
align-items: center;
padding: 0.45rem 1.1rem;
border: none;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-edit:hover {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
opacity: 0.95;
color: #fff;
}
.btn-advance {
padding: 0.45rem 1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: opacity 0.15s;
}
.btn-advance:hover {
opacity: 0.88;
}
.btn-convert {
margin-left: auto;
padding: 0.3rem 0.75rem;
@@ -381,11 +475,45 @@ const subTaskProgress = computed(() => {
cursor: default;
}
.meta {
.btn-share {
padding: 0.3rem 0.75rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
}
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
.task-title {
font-family: "Fraunces", Georgia, serif;
font-size: 2rem;
font-weight: 700;
line-height: 1.2;
margin: 0.25rem 0 0.5rem;
color: var(--color-text);
}
.meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.83rem;
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.meta-sep {
opacity: 0.5;
}
.badges {
display: flex;
align-items: center;
@@ -510,41 +638,116 @@ const subTaskProgress = computed(() => {
}
.backlinks {
margin-top: 2rem;
margin-top: 2.5rem;
border-top: 1px solid var(--color-border);
padding-top: 1rem;
padding-top: 1.25rem;
}
.backlinks h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
}
.backlinks-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.backlink-item {
.backlinks-heading {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.backlink-link {
color: var(--color-text);
.backlinks-count {
margin-left: 0.2rem;
font-size: 0.72rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0 0.4rem;
line-height: 1.4;
}
.backlinks-grid {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.backlink-card {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
text-decoration: none;
font-size: 0.95rem;
color: var(--color-text);
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.9rem;
}
.backlink-link:hover {
.backlink-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
color: var(--color-primary);
}
.backlink-type {
font-size: 0.75rem;
.backlink-type-badge {
font-size: 0.68rem;
text-transform: uppercase;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 0.1rem 0.4rem;
border-radius: var(--radius-sm);
letter-spacing: 0.04em;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
}
.badge-note {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.badge-task {
background: color-mix(in srgb, #f59e0b 12%, transparent);
color: #d97706;
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
}
.backlink-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Skeleton loader ── */
@keyframes skel-shine {
to { background-position: 200% center; }
}
.viewer-skeleton {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding-top: 0.5rem;
}
.skel-btn,
.skel-title,
.skel-meta,
.skel-badges,
.skel-line {
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-secondary) 25%,
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
var(--color-bg-secondary) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
}
.skel-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.skel-btn { width: 70px; height: 32px; }
.skel-btn--wide { width: 90px; }
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--radius-md); }
.skel-meta { height: 0.85rem; width: 45%; }
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
.skel-line { height: 0.9rem; }
.skel-line--short { width: 50%; }
.skel-line--medium { width: 78%; }
</style>
+185 -57
View File
@@ -8,9 +8,8 @@ import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigati
import SearchBar from "@/components/SearchBar.vue";
import TaskCard from "@/components/TaskCard.vue";
import TagPill from "@/components/TagPill.vue";
import PaginationBar from "@/components/PaginationBar.vue";
type ViewMode = "flat" | "grouped";
type ViewMode = "smart" | "grouped";
const route = useRoute();
const router = useRouter();
@@ -22,29 +21,23 @@ function onFocusSearch() {
searchBarRef.value?.focus();
}
const viewMode = ref<ViewMode>(
(localStorage.getItem("fabled-tasks-view-mode") as ViewMode) ?? "flat"
);
const _storedMode = localStorage.getItem("fabled-tasks-view-mode");
const viewMode = ref<ViewMode>(_storedMode === "grouped" ? "grouped" : "smart");
function setViewMode(mode: ViewMode) {
viewMode.value = mode;
localStorage.setItem("fabled-tasks-view-mode", mode);
if (mode === "grouped") {
store.limit = 100;
store.offset = 0;
} else {
store.limit = 20;
store.offset = 0;
}
store.limit = mode === "grouped" ? 100 : 200;
store.offset = 0;
store.refresh();
}
const isFlat = computed(() => viewMode.value === "flat");
const { activeIndex } = useListKeyboardNavigation(
// Keyboard nav disabled — smart sections span multiple containers
useListKeyboardNavigation(
computed(() => store.tasks),
(task) => router.push(`/tasks/${task.id}`),
".kb-active-item",
isFlat,
computed(() => false),
);
// Project map for group labels and task breadcrumbs
@@ -66,6 +59,36 @@ interface TaskGroup {
tasks: Task[];
}
interface TaskSection {
key: string;
label: string;
tasks: Task[];
}
const smartSections = computed<TaskSection[]>(() => {
if (viewMode.value !== "smart") return [];
const today = new Date().toISOString().slice(0, 10);
const weekEnd = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const overdue: Task[] = [], dueToday: Task[] = [], thisWeek: Task[] = [],
upcoming: Task[] = [], noDueDate: Task[] = [], done: Task[] = [];
for (const task of store.tasks) {
if (task.status === "done") { done.push(task); continue; }
if (!task.due_date) { noDueDate.push(task); continue; }
if (task.due_date < today) { overdue.push(task); continue; }
if (task.due_date === today) { dueToday.push(task); continue; }
if (task.due_date <= weekEnd) { thisWeek.push(task); continue; }
upcoming.push(task);
}
const sections: TaskSection[] = [];
if (overdue.length) sections.push({ key: "overdue", label: "Overdue", tasks: overdue });
if (dueToday.length) sections.push({ key: "today", label: "Due Today", tasks: dueToday });
if (thisWeek.length) sections.push({ key: "week", label: "This Week", tasks: thisWeek });
if (upcoming.length) sections.push({ key: "upcoming", label: "Upcoming", tasks: upcoming });
if (noDueDate.length) sections.push({ key: "no-date", label: "No Due Date", tasks: noDueDate });
if (done.length) sections.push({ key: "done", label: "Completed", tasks: done });
return sections;
});
const groupedTasks = computed<TaskGroup[]>(() => {
if (viewMode.value !== "grouped") return [];
const map = new Map<number | null, Task[]>();
@@ -101,9 +124,8 @@ onMounted(async () => {
if (route.query.status) {
store.statusFilter = route.query.status as TaskStatus;
}
if (viewMode.value === "grouped") {
store.limit = 100;
}
store.limit = viewMode.value === "grouped" ? 100 : 200;
collapsedGroups.value.add("done");
await Promise.all([store.refresh(), loadProjects()]);
document.addEventListener("shortcut:focus-search", onFocusSearch);
});
@@ -167,10 +189,6 @@ function onStatusToggle(id: number, status: TaskStatus) {
store.patchStatus(id, status);
}
function onOffsetUpdate(offset: number) {
store.setOffset(offset);
}
// Collapse state for grouped sections
const collapsedGroups = ref<Set<string>>(new Set());
function toggleGroup(key: string) {
@@ -222,9 +240,9 @@ function toggleGroup(key: string) {
<!-- View mode toggle -->
<div class="view-toggle">
<button
:class="['toggle-btn', { active: viewMode === 'flat' }]"
title="Flat list"
@click="setViewMode('flat')"
:class="['toggle-btn', { active: viewMode === 'smart' }]"
title="Smart sections (by due date)"
@click="setViewMode('smart')"
></button>
<button
:class="['toggle-btn', { active: viewMode === 'grouped' }]"
@@ -247,7 +265,9 @@ function toggleGroup(key: string) {
<button class="clear-filters" @click="store.clearTagFilters()">Clear all</button>
</div>
<p v-if="store.loading">Loading...</p>
<div v-if="store.loading" class="task-list-skeleton">
<div class="skeleton-row" v-for="i in 6" :key="i"></div>
</div>
<div v-else-if="store.tasks.length === 0" class="empty-state">
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter || store.priorityFilter">
@@ -255,9 +275,12 @@ function toggleGroup(key: string) {
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
<template v-else>
<p class="empty-title">No tasks yet</p>
<p class="empty-subtitle">Create your first task to get started.</p>
<router-link to="/tasks/new" class="btn-cta">+ New Task</router-link>
<div class="empty-state-rich">
<div class="empty-icon"></div>
<p class="empty-title">No tasks yet</p>
<p class="empty-sub">Create your first task to start tracking work</p>
<router-link to="/tasks/new" class="empty-action">New task </router-link>
</div>
</template>
</div>
@@ -295,30 +318,28 @@ function toggleGroup(key: string) {
</div>
</template>
<!-- Flat compact list -->
<div v-else class="cards-list">
<div
v-for="(task, i) in store.tasks"
:key="task.id"
:class="{ 'kb-active-item': activeIndex === i }"
>
<TaskCard
:task="task"
compact
:project-title="task.project_id ? (projectMap.get(task.project_id) ?? undefined) : undefined"
@tag-click="onTagClick"
@status-toggle="onStatusToggle"
/>
<!-- Smart sections view (by due date) -->
<template v-else>
<div v-for="section in smartSections" :key="section.key" class="task-section">
<button class="section-header" @click="toggleGroup(section.key)">
<span :class="['section-dot', `dot-${section.key}`]"></span>
<span class="section-label">{{ section.label }}</span>
<span class="section-count">{{ section.tasks.length }}</span>
<span class="section-chevron">{{ collapsedGroups.has(section.key) ? "▶" : "▼" }}</span>
</button>
<div v-show="!collapsedGroups.has(section.key)" class="section-tasks">
<TaskCard
v-for="task in section.tasks"
:key="task.id"
:task="task"
compact
:project-title="task.project_id ? (projectMap.get(task.project_id) ?? undefined) : undefined"
@tag-click="onTagClick"
@status-toggle="onStatusToggle"
/>
</div>
</div>
</div>
<PaginationBar
v-if="viewMode === 'flat'"
:total="store.total"
:limit="store.limit"
:offset="store.offset"
@update:offset="onOffsetUpdate"
/>
</template>
</main>
</template>
@@ -340,11 +361,16 @@ function toggleGroup(key: string) {
}
.btn-new {
padding: 0.45rem 1rem;
background: var(--color-primary);
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
box-shadow: 0 1px 6px rgba(99, 102, 241, 0.25);
}
.btn-new:hover {
box-shadow: 0 3px 12px rgba(99, 102, 241, 0.45);
filter: brightness(1.08);
}
.controls {
display: flex;
@@ -426,12 +452,80 @@ function toggleGroup(key: string) {
padding: 0;
}
/* Flat compact list */
.cards-list {
/* Skeleton loading */
.task-list-skeleton {
margin-top: 1rem;
}
.skeleton-row {
height: 44px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
margin-bottom: 0.3rem;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Smart sections */
.task-section {
margin-top: 1rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: none;
border: none;
border-bottom: 1px solid var(--color-border);
padding: 0.3rem 0;
cursor: pointer;
text-align: left;
color: var(--color-text);
}
.section-header:hover .section-label {
color: var(--color-primary);
}
.section-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-overdue { background: var(--color-danger, #e74c3c); }
.dot-today { background: var(--color-primary); }
.dot-week { background: #f59e0b; }
.dot-upcoming { background: var(--color-text-secondary); }
.dot-no-date { background: var(--color-text-muted); }
.dot-done { background: var(--color-status-done, #22c55e); }
.section-label {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
flex: 1;
transition: color 0.15s;
}
.section-count {
font-size: 0.75rem;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.section-chevron {
font-size: 0.65rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.section-tasks {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 1rem;
gap: 0.35rem;
padding: 0.5rem 0 0.25rem;
}
/* Grouped view */
@@ -513,6 +607,40 @@ function toggleGroup(key: string) {
text-decoration: none;
font-size: 0.9rem;
}
.empty-state-rich {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
opacity: 0.3;
}
.empty-state-rich .empty-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary);
margin: 0 0 0.35rem;
}
.empty-sub {
font-size: 0.85rem;
margin: 0 0 1rem;
}
.empty-action {
display: inline-block;
padding: 0.4rem 1rem;
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
color: var(--color-primary);
text-decoration: none;
font-size: 0.85rem;
transition: background 0.15s, color 0.15s;
}
.empty-action:hover {
background: var(--color-primary);
color: #fff;
}
.kb-active-item {
outline: 2px solid var(--color-primary);
+151 -29
View File
@@ -22,6 +22,7 @@ const projectId = computed(() => Number(route.params.projectId));
interface Project {
id: number;
title: string;
goal?: string;
}
const project = ref<Project | null>(null);
@@ -56,12 +57,11 @@ function _loadPanelState(pid: number) {
const panelOpen = ref({ tasks: true, chat: true, notes: true });
const gridColumns = computed(() => {
const cols = [
panelOpen.value.tasks ? "1fr" : "0px",
panelOpen.value.chat ? "1fr" : "0px",
panelOpen.value.notes ? "1fr" : "0px",
];
return cols.join(" ");
return [
panelOpen.value.tasks ? "minmax(0, 0.8fr)" : "0px",
panelOpen.value.chat ? "minmax(0, 1.1fr)" : "0px",
panelOpen.value.notes ? "minmax(0, 1.1fr)" : "0px",
].join(" ");
});
// SSE watcher
@@ -124,9 +124,14 @@ function togglePanel(panel: keyof typeof panelOpen.value) {
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
}
function prefill(text: string) {
messageInput.value = text;
nextTick(() => inputEl.value?.focus());
}
async function sendMessage() {
const content = messageInput.value.trim();
if (!content || chatStore.streaming) return;
if (!content) return;
messageInput.value = "";
resetTextareaHeight();
@@ -230,7 +235,7 @@ onUnmounted(async () => {
<router-link :to="project ? `/projects/${project.id}` : '/projects'" class="ws-back">
{{ project?.title ?? "Project" }}
</router-link>
<span class="ws-title">Workspace</span>
<span class="ws-title">{{ project?.goal ?? '' }}</span>
<div class="ws-panel-toggles">
<button
:class="['panel-toggle', { active: panelOpen.tasks }]"
@@ -261,11 +266,15 @@ onUnmounted(async () => {
<!-- Left: Tasks -->
<div v-show="panelOpen.tasks" class="ws-panel">
<WorkspaceTaskPanel
v-if="project"
ref="taskPanelRef"
:project-id="project.id"
/>
<Transition name="panel-fade">
<div v-if="panelOpen.tasks" class="panel-inner">
<WorkspaceTaskPanel
v-if="project"
ref="taskPanelRef"
:project-id="project.id"
/>
</div>
</Transition>
</div>
<!-- Center: Chat -->
@@ -318,12 +327,36 @@ onUnmounted(async () => {
</div>
</div>
<p
<!-- Queued messages shown as pending bubbles -->
<template v-if="chatStore.queuedMessages.length">
<div
v-for="(q, i) in chatStore.queuedMessages"
:key="`queued-${i}`"
class="ws-message role-user queued-message"
>
<div class="message-bubble queued-bubble">
<div class="queued-badge">Queued</div>
<div class="message-content">{{ q.content }}</div>
</div>
</div>
<div class="queued-clear-row">
<button class="queued-clear-btn" @click="chatStore.clearQueue()" aria-label="Cancel queued messages">
Cancel {{ chatStore.queuedMessages.length }} queued
</button>
</div>
</template>
<div
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
class="empty-chat-msg"
class="empty-chat-prompt"
>
Ask the agent to create notes or tasks for this project.
</p>
<p class="empty-hint">What would you like to work on?</p>
<div class="quick-chips">
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
<button class="quick-chip" @click="prefill('Add tasks for ')"> Add tasks</button>
</div>
</div>
</div>
<div class="chat-input-area">
@@ -331,9 +364,8 @@ onUnmounted(async () => {
ref="inputEl"
v-model="messageInput"
class="chat-input"
placeholder="Message the agent... (Enter to send)"
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent (Enter to send)'"
rows="1"
:disabled="chatStore.streaming"
@keydown="onInputKeydown"
@input="autoResize"
></textarea>
@@ -358,11 +390,15 @@ onUnmounted(async () => {
<!-- Right: Note Editor -->
<div v-show="panelOpen.notes" class="ws-panel">
<WorkspaceNoteEditor
v-if="project"
:project-id="project.id"
:active-note-id="activeNoteId"
/>
<Transition name="panel-fade">
<div v-if="panelOpen.notes" class="panel-inner">
<WorkspaceNoteEditor
v-if="project"
:project-id="project.id"
:active-note-id="activeNoteId"
/>
</div>
</Transition>
</div>
</div>
@@ -402,10 +438,13 @@ onUnmounted(async () => {
}
.ws-title {
font-weight: 600;
font-size: 0.9rem;
font-size: 0.78rem;
color: var(--color-text-muted);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-style: italic;
}
.ws-panel-toggles {
@@ -443,6 +482,21 @@ onUnmounted(async () => {
min-width: 0;
}
.panel-inner {
height: 100%;
display: flex;
flex-direction: column;
}
.panel-fade-enter-active,
.panel-fade-leave-active {
transition: opacity 0.18s ease;
}
.panel-fade-enter-from,
.panel-fade-leave-to {
opacity: 0;
}
/* Chat panel */
.ws-chat-panel {
display: flex;
@@ -460,12 +514,37 @@ onUnmounted(async () => {
gap: 0.75rem;
}
.empty-chat-msg {
color: var(--color-text-muted);
font-size: 0.875rem;
.empty-chat-prompt {
text-align: center;
padding: 2rem 1rem;
}
.empty-hint {
margin: 0 0 1rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.quick-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
}
.quick-chip {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 0.4rem 0.85rem;
font-size: 0.82rem;
color: var(--color-text-secondary);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
font-family: inherit;
}
.quick-chip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
}
.chat-input-area {
display: flex;
@@ -625,4 +704,47 @@ details[open] .thinking-summary::before {
max-height: 300px;
overflow-y: auto;
}
.ws-message.role-user {
display: flex;
justify-content: flex-end;
}
.queued-bubble {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 18px;
border-bottom-right-radius: 4px;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
opacity: 0.45;
font-size: 0.95rem;
line-height: 1.55;
word-break: break-word;
}
.queued-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 0.2rem;
}
.queued-clear-row {
display: flex;
justify-content: flex-end;
padding-right: 0.5rem;
}
.queued-clear-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.78rem;
padding: 0.2rem 0.6rem;
font-family: inherit;
}
.queued-clear-btn:hover {
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
</style>
+35
View File
@@ -0,0 +1,35 @@
# Runner base image for Forgejo act_runner job containers.
# Pre-installs Python 3.12 and Node 20 so the test job skips the
# ~3-4 minute deadsnakes PPA install on every run.
#
# Build and push (one-time setup, re-run if this file changes):
# docker build -f infra/Dockerfile.runner-base \
# -t git.fabledsword.com/bvandeusen/runner-base:py3.12-node22 .
# docker push git.fabledsword.com/bvandeusen/runner-base:py3.12-node22
#
# Then redeploy the act_runner stack in Portainer to pick up the new label.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Split into separate RUN steps so each pushed layer is smaller.
# One combined layer would be ~280MB and exceeds the nginx upload timeout.
# Python 3.12 ships in Ubuntu 24.04 main repos — no PPA needed.
RUN apt-get update -qq && \
apt-get install -y -qq \
python3.12 python3.12-dev python3.12-venv python3-pip \
git curl ca-certificates gnupg && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Node 22 LTS via NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y -qq nodejs && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Docker CLI — daemon comes from the host socket mount, only CLI needed.
# docker.io from Ubuntu repos is sufficient for docker login + buildx.
RUN apt-get update -qq && \
apt-get install -y -qq docker.io && \
apt-get clean && rm -rf /var/lib/apt/lists/*
+4
View File
@@ -8,6 +8,10 @@ log:
runner:
capacity: 2 # max concurrent jobs
timeout: 30m
# Maps runs-on labels to Docker images for job containers.
# Change this (and delete /data/.runner) to switch the base image.
labels:
- "py3.12-node22:docker://git.fabledsword.com/bvandeusen/runner-base:py3.12-node22"
cache:
enabled: true
-1
View File
@@ -13,7 +13,6 @@ services:
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
- GITEA_RUNNER_NAME=swarm-runner
# Maps the runs-on: ubuntu-latest label used in workflow files to a Docker image.
- GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bullseye
- CONFIG_FILE=/config/config.yml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+2
View File
@@ -18,6 +18,8 @@ dependencies = [
"aiosmtplib>=3.0",
"caldav>=1.3",
"icalendar>=5.0",
"feedparser>=6.0",
"APScheduler>=3.10,<4.0",
"pywebpush>=2.0",
]
+38 -2
View File
@@ -11,6 +11,7 @@ from fabledassistant.config import Config
from fabledassistant.routes.admin import admin_bp
from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.briefing import briefing_bp
from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.export import export_bp
from fabledassistant.routes.notes import notes_bp
@@ -22,6 +23,10 @@ from fabledassistant.routes.push import push_bp
from fabledassistant.routes.quick_capture import quick_capture_bp
from fabledassistant.routes.settings import settings_bp
from fabledassistant.routes.tasks import tasks_bp
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
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
@@ -56,6 +61,7 @@ def create_app() -> Quart:
app.register_blueprint(admin_bp)
app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(briefing_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(export_bp)
app.register_blueprint(images_bp)
@@ -67,6 +73,10 @@ def create_app() -> Quart:
app.register_blueprint(settings_bp)
app.register_blueprint(task_logs_bp)
app.register_blueprint(tasks_bp)
app.register_blueprint(groups_bp)
app.register_blueprint(shares_bp)
app.register_blueprint(notifications_bp)
app.register_blueprint(users_bp)
@app.before_request
async def before_request():
@@ -230,6 +240,15 @@ def create_app() -> Quart:
asyncio.create_task(_delayed_backfill())
# Start briefing scheduler
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
start_briefing_scheduler(asyncio.get_event_loop())
@app.after_serving
async def shutdown():
from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler
stop_briefing_scheduler()
@app.route("/")
async def serve_index():
resp = await make_response(
@@ -238,17 +257,34 @@ def create_app() -> Quart:
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return resp
# File extensions that belong to the SPA or its assets.
# Anything else with an extension is not a valid app path and gets a hard 404.
_SPA_EXTENSIONS = {
"", ".html", ".js", ".css", ".ico", ".png", ".jpg", ".jpeg",
".svg", ".webp", ".woff", ".woff2", ".ttf", ".otf", ".map", ".json",
}
@app.errorhandler(404)
async def handle_404(error):
# Return JSON 404 for API routes
if request.path.startswith("/api/"):
return jsonify({"error": "Not found"}), 404
# Try to serve static file
# Try to serve a real static file first
path = request.path.lstrip("/")
file_path = STATIC_DIR / path
if path and file_path.is_file():
return await send_from_directory(STATIC_DIR, path)
# SPA fallback
# Reject paths with file extensions the app doesn't serve.
# This turns scanner probes (.php, .asp, .cgi, etc.) into honest 404s
# instead of serving them the Vue SPA with a misleading 200.
from pathlib import PurePosixPath
suffix = PurePosixPath(request.path).suffix.lower()
if suffix not in _SPA_EXTENSIONS:
return "", 404
# SPA fallback for clean client-side routes (/notes/123, /chat, etc.)
resp = await make_response(
await send_from_directory(STATIC_DIR, "index.html")
)
+5
View File
@@ -35,3 +35,8 @@ from fabledassistant.models.milestone import Milestone # noqa: E402, F401
from fabledassistant.models.task_log import TaskLog # noqa: E402, F401
from fabledassistant.models.note_draft import NoteDraft # noqa: E402, F401
from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401
from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401
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
+10 -1
View File
@@ -1,4 +1,6 @@
from sqlalchemy import ForeignKey, Index, Integer, Text
import datetime
from sqlalchemy import Date, ForeignKey, Index, Integer, Text
from sqlalchemy import inspect
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -16,6 +18,11 @@ class Conversation(Base, TimestampMixin):
)
title: Mapped[str] = mapped_column(Text, default="")
model: Mapped[str] = mapped_column(Text, default="")
# 'chat' (default) or 'briefing'. Briefing conversations are hidden from
# the main chat view and managed by the briefing pipeline.
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)
messages: Mapped[list["Message"]] = relationship(
back_populates="conversation",
@@ -38,6 +45,8 @@ class Conversation(Base, TimestampMixin):
"id": self.id,
"title": self.title,
"model": self.model,
"conversation_type": self.conversation_type,
"briefing_date": self.briefing_date.isoformat() if self.briefing_date else None,
"message_count": msg_count,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime, timezone
from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
class Group(Base, TimestampMixin):
__tablename__ = "groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
description: Mapped[str | None] = mapped_column(Text)
created_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL")
)
memberships: Mapped[list["GroupMembership"]] = relationship(
"GroupMembership", back_populates="group", cascade="all, delete-orphan"
)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"description": self.description,
"created_by": self.created_by,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
class GroupMembership(Base, CreatedAtMixin):
__tablename__ = "group_memberships"
__table_args__ = (UniqueConstraint("group_id", "user_id", name="uq_gm_group_user"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
role: Mapped[str] = mapped_column(Text, nullable=False, default="member")
group: Mapped["Group"] = relationship("Group", back_populates="memberships")
def to_dict(self) -> dict:
return {
"id": self.id,
"group_id": self.group_id,
"user_id": self.user_id,
"role": self.role,
"created_at": self.created_at.isoformat(),
}
@@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class Notification(Base, CreatedAtMixin):
__tablename__ = "notifications"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# project_shared | note_shared | group_added
type: Mapped[str] = mapped_column(Text, nullable=False)
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"type": self.type,
"payload": self.payload,
"read_at": self.read_at.isoformat() if self.read_at else None,
"created_at": self.created_at.isoformat(),
}
+70
View File
@@ -0,0 +1,70 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
class RssFeed(Base):
__tablename__ = "rss_feeds"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
url: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
category: Mapped[str | None] = mapped_column(Text, nullable=True)
last_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
items: Mapped[list["RssItem"]] = relationship(
back_populates="feed", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint("user_id", "url", name="uq_rss_feeds_user_url"),
Index("ix_rss_feeds_user_id", "user_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"url": self.url,
"title": self.title,
"category": self.category,
"last_fetched_at": self.last_fetched_at.isoformat() if self.last_fetched_at else None,
}
class RssItem(Base):
__tablename__ = "rss_items"
id: Mapped[int] = mapped_column(primary_key=True)
feed_id: Mapped[int] = mapped_column(Integer, ForeignKey("rss_feeds.id", ondelete="CASCADE"))
guid: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
url: Mapped[str] = mapped_column(Text, default="")
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Truncated to 2000 chars to keep DB size reasonable
content: Mapped[str] = mapped_column(Text, default="")
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
feed: Mapped["RssFeed"] = relationship(back_populates="items")
__table_args__ = (
UniqueConstraint("feed_id", "guid", name="uq_rss_items_feed_guid"),
Index("ix_rss_items_feed_id", "feed_id"),
Index("ix_rss_items_published_at", "published_at"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"feed_id": self.feed_id,
"guid": self.guid,
"title": self.title,
"url": self.url,
"published_at": self.published_at.isoformat() if self.published_at else None,
"content": self.content,
}
+79
View File
@@ -0,0 +1,79 @@
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import TimestampMixin
class ProjectShare(Base, TimestampMixin):
__tablename__ = "project_shares"
__table_args__ = (
CheckConstraint(
"(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)",
name="ck_ps_exclusive_target",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
shared_with_user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE")
)
shared_with_group_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("groups.id", ondelete="CASCADE")
)
permission: Mapped[str] = mapped_column(Text, nullable=False)
invited_by: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
def to_dict(self) -> dict:
return {
"id": self.id,
"project_id": self.project_id,
"shared_with_user_id": self.shared_with_user_id,
"shared_with_group_id": self.shared_with_group_id,
"permission": self.permission,
"invited_by": self.invited_by,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
class NoteShare(Base, TimestampMixin):
__tablename__ = "note_shares"
__table_args__ = (
CheckConstraint(
"(shared_with_user_id IS NOT NULL) != (shared_with_group_id IS NOT NULL)",
name="ck_ns_exclusive_target",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
note_id: Mapped[int] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
shared_with_user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE")
)
shared_with_group_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("groups.id", ondelete="CASCADE")
)
permission: Mapped[str] = mapped_column(Text, nullable=False)
invited_by: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
def to_dict(self) -> dict:
return {
"id": self.id,
"note_id": self.note_id,
"shared_with_user_id": self.shared_with_user_id,
"shared_with_group_id": self.shared_with_group_id,
"permission": self.permission,
"invited_by": self.invited_by,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -0,0 +1,38 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class WeatherCache(Base):
__tablename__ = "weather_cache"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
# Unique key per location, e.g. "home", "work", or "event:<uid>"
location_key: Mapped[str] = mapped_column(Text)
location_label: Mapped[str] = mapped_column(Text, default="")
# Current 7-day forecast from Open-Meteo (raw JSON)
forecast_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Previous forecast — used to detect changes between fetches
previous_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
UniqueConstraint("user_id", "location_key", name="uq_weather_cache_user_location"),
Index("ix_weather_cache_user_id", "user_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"location_key": self.location_key,
"location_label": self.location_label,
"forecast_json": self.forecast_json,
"fetched_at": self.fetched_at.isoformat(),
}
+7
View File
@@ -1,3 +1,5 @@
import os
from quart import Blueprint, jsonify
api = Blueprint("api", __name__, url_prefix="/api")
@@ -6,3 +8,8 @@ api = Blueprint("api", __name__, url_prefix="/api")
@api.route("/health")
async def health():
return jsonify({"status": "ok"})
@api.route("/version")
async def version():
return jsonify({"version": os.environ.get("APP_VERSION", "dev")})
+234
View File
@@ -0,0 +1,234 @@
"""Briefing API: RSS feeds, weather, and briefing configuration."""
import asyncio
import json
import logging
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from fabledassistant.auth import login_required
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.models.rss_feed import RssFeed
from fabledassistant.services import rss as rss_svc
from fabledassistant.services import weather as weather_svc
from fabledassistant.services.briefing_conversations import (
get_or_create_today_conversation,
list_briefing_conversations,
post_message,
)
from fabledassistant.services.settings import get_setting, set_settings_batch
logger = logging.getLogger(__name__)
briefing_bp = Blueprint("briefing", __name__, url_prefix="/api/briefing")
_REQUIRE = login_required
# ── Config ────────────────────────────────────────────────────────────────────
@briefing_bp.route("/config", methods=["GET"])
@_REQUIRE
async def get_config():
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
except Exception:
config = {}
return jsonify(config)
@briefing_bp.route("/config", methods=["PUT"])
@_REQUIRE
async def put_config():
data = await request.get_json()
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
return jsonify({"ok": True})
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
@briefing_bp.route("/feeds", methods=["GET"])
@_REQUIRE
async def list_feeds():
async with async_session() as session:
result = await session.execute(
select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id)
)
feeds = list(result.scalars().all())
return jsonify([f.to_dict() for f in feeds])
@briefing_bp.route("/feeds", methods=["POST"])
@_REQUIRE
async def add_feed():
data = await request.get_json()
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "url required"}), 400
category = data.get("category") or None
async with async_session() as session:
# Prevent duplicates
existing = await session.execute(
select(RssFeed).where(RssFeed.user_id == g.user.id, RssFeed.url == url)
)
if existing.scalars().first():
return jsonify({"error": "Feed already added"}), 409
feed = RssFeed(user_id=g.user.id, url=url, title="", category=category)
session.add(feed)
await session.commit()
await session.refresh(feed)
feed_id = feed.id
# Fetch in background to auto-populate title and get initial items
asyncio.create_task(rss_svc.fetch_and_cache_feed(feed_id, url))
return jsonify({"id": feed_id, "url": url, "title": "", "category": category}), 201
@briefing_bp.route("/feeds/<int:feed_id>", methods=["DELETE"])
@_REQUIRE
async def delete_feed(feed_id: int):
async with async_session() as session:
result = await session.execute(
select(RssFeed).where(RssFeed.id == feed_id, RssFeed.user_id == g.user.id)
)
feed = result.scalars().first()
if not feed:
return jsonify({"error": "Not found"}), 404
await session.delete(feed)
await session.commit()
return jsonify({"ok": True})
@briefing_bp.route("/feeds/refresh", methods=["POST"])
@_REQUIRE
async def refresh_feeds():
results = await rss_svc.refresh_all_feeds(g.user.id)
total_new = sum(results.values())
return jsonify({"feeds_refreshed": len(results), "new_items": total_new})
@briefing_bp.route("/feeds/recent", methods=["GET"])
@_REQUIRE
async def recent_items():
limit = min(int(request.args.get("limit", 20)), 100)
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
return jsonify({"items": items})
# ── Weather ───────────────────────────────────────────────────────────────────
@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})
@briefing_bp.route("/weather/geocode", methods=["POST"])
@_REQUIRE
async def geocode_location():
data = await request.get_json()
query = (data.get("query") or "").strip()
if not query:
return jsonify({"error": "query required"}), 400
try:
lat, lon, label = await weather_svc.geocode(query)
return jsonify({"lat": lat, "lon": lon, "label": label})
except ValueError as e:
return jsonify({"error": str(e)}), 404
@briefing_bp.route("/weather/refresh", methods=["POST"])
@_REQUIRE
async def refresh_weather():
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else {}
except Exception:
config = {}
locations = config.get("locations", {})
refreshed = []
for key, loc in locations.items():
if not loc.get("lat") or not loc.get("lon"):
continue
try:
await weather_svc.refresh_location_cache(
user_id=g.user.id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
refreshed.append(key)
except Exception:
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
return jsonify({"refreshed": refreshed})
# ── Briefing Conversations ─────────────────────────────────────────────────────
@briefing_bp.route("/conversations", methods=["GET"])
@_REQUIRE
async def get_conversations():
convs = await list_briefing_conversations(g.user.id)
return jsonify({"conversations": convs})
@briefing_bp.route("/conversations/today", methods=["GET"])
@_REQUIRE
async def get_today_conversation():
model = await get_setting(g.user.id, "default_model", "")
conv = await get_or_create_today_conversation(g.user.id, model)
# Load messages
async with async_session() as session:
result = await session.execute(
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at)
)
messages = [m.to_dict() for m in result.scalars().all()]
data = conv.to_dict()
data["messages"] = messages
return jsonify(data)
@briefing_bp.route("/conversations/<int:conv_id>/messages", methods=["GET"])
@_REQUIRE
async def get_conversation_messages(conv_id: int):
# Verify ownership
async with async_session() as session:
conv = await session.get(Conversation, conv_id)
if not conv or conv.user_id != g.user.id or conv.conversation_type != "briefing":
return jsonify({"error": "Not found"}), 404
result = await session.execute(
select(Message)
.where(Message.conversation_id == conv_id)
.order_by(Message.created_at)
)
messages = [m.to_dict() for m in result.scalars().all()]
return jsonify({"messages": messages})
@briefing_bp.route("/trigger", methods=["POST"])
@_REQUIRE
async def manual_trigger():
"""Dev/admin endpoint to manually trigger a briefing compilation."""
data = await request.get_json() or {}
slot = data.get("slot", "compilation")
if slot not in ("compilation", "morning", "midday", "afternoon"):
return jsonify({"error": "invalid slot"}), 400
from fabledassistant.services.briefing_pipeline import run_compilation
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)
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
+2 -1
View File
@@ -40,6 +40,7 @@ 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)
conv_type = request.args.get("type", "chat")
# Apply retention policy before returning list
retention_str = await get_setting(uid, "chat_retention_days", "90")
try:
@@ -48,7 +49,7 @@ async def list_conversations_route():
retention_days = 90
if retention_days > 0:
await cleanup_old_conversations(uid, retention_days)
conversations, total = await list_conversations(uid, limit=limit, offset=offset)
conversations, total = await list_conversations(uid, limit=limit, offset=offset, conv_type=conv_type)
return jsonify({
"conversations": conversations,
"total": total,
+112
View File
@@ -0,0 +1,112 @@
import asyncio
from quart import Blueprint, g, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.services import groups as group_svc
from fabledassistant.services.notifications import notify_group_added
groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups")
@groups_bp.route("", methods=["GET"])
@login_required
async def list_groups():
uid = get_current_user_id()
return jsonify({"groups": await group_svc.list_groups(uid)})
@groups_bp.route("", methods=["POST"])
@login_required
async def create_group():
uid = get_current_user_id()
data = await request.get_json()
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "Name is required"}), 400
try:
group = await group_svc.create_group(uid, name, data.get("description"))
except Exception:
return jsonify({"error": "Group name already taken"}), 409
return jsonify(group.to_dict()), 201
@groups_bp.route("/<int:group_id>", methods=["GET"])
@login_required
async def get_group(group_id: int):
group = await group_svc.get_group(group_id)
if not group:
return jsonify({"error": "Not found"}), 404
members = await group_svc.list_members(group_id)
return jsonify({**group.to_dict(), "members": members})
@groups_bp.route("/<int:group_id>", methods=["PATCH"])
@login_required
async def update_group(group_id: int):
uid = get_current_user_id()
data = await request.get_json()
updates = {k: v for k, v in data.items() if k in ("name", "description")}
group = await group_svc.update_group(uid, group_id, g.user.role == "admin", **updates)
if not group:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(group.to_dict())
@groups_bp.route("/<int:group_id>", methods=["DELETE"])
@login_required
async def delete_group(group_id: int):
uid = get_current_user_id()
deleted = await group_svc.delete_group(uid, group_id, g.user.role == "admin")
if not deleted:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
@groups_bp.route("/<int:group_id>/members", methods=["GET"])
@login_required
async def list_members(group_id: int):
members = await group_svc.list_members(group_id)
return jsonify({"members": members})
@groups_bp.route("/<int:group_id>/members", methods=["POST"])
@login_required
async def add_member(group_id: int):
uid = get_current_user_id()
data = await request.get_json()
target_uid = data.get("user_id")
role = data.get("role", "member")
if not target_uid:
return jsonify({"error": "user_id required"}), 400
if role not in ("owner", "member"):
return jsonify({"error": "role must be owner or member"}), 400
membership = await group_svc.add_member(uid, group_id, target_uid, role, g.user.role == "admin")
if not membership:
return jsonify({"error": "Forbidden, group not found, or user already a member"}), 403
asyncio.create_task(notify_group_added(group_id, role, uid, target_uid))
return jsonify(membership.to_dict()), 201
@groups_bp.route("/<int:group_id>/members/<int:target_uid>", methods=["PATCH"])
@login_required
async def update_member(group_id: int, target_uid: int):
uid = get_current_user_id()
data = await request.get_json()
role = data.get("role")
if role not in ("owner", "member"):
return jsonify({"error": "role must be owner or member"}), 400
m = await group_svc.update_member_role(uid, group_id, target_uid, role, g.user.role == "admin")
if not m:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(m.to_dict())
@groups_bp.route("/<int:group_id>/members/<int:target_uid>", methods=["DELETE"])
@login_required
async def remove_member(group_id: int, target_uid: int):
uid = get_current_user_id()
removed = await group_svc.remove_member(uid, group_id, target_uid, g.user.role == "admin")
if not removed:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
@@ -0,0 +1,44 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.services.notifications import (
list_in_app_notifications,
mark_all_notifications_read,
mark_notification_read,
unread_notification_count,
)
notifications_bp = Blueprint("notifications", __name__, url_prefix="/api/notifications")
@notifications_bp.route("", methods=["GET"])
@login_required
async def list_notifications():
uid = get_current_user_id()
all_flag = request.args.get("all", "false").lower() == "true"
items = await list_in_app_notifications(uid, unread_only=not all_flag)
return jsonify({"notifications": items})
@notifications_bp.route("/count", methods=["GET"])
@login_required
async def get_count():
uid = get_current_user_id()
return jsonify({"count": await unread_notification_count(uid)})
@notifications_bp.route("/<int:notif_id>/read", methods=["POST"])
@login_required
async def mark_read(notif_id: int):
uid = get_current_user_id()
if not await mark_notification_read(uid, notif_id):
return jsonify({"error": "Not found"}), 404
return jsonify({"status": "ok"})
@notifications_bp.route("/read-all", methods=["POST"])
@login_required
async def mark_all_read():
uid = get_current_user_id()
count = await mark_all_notifications_read(uid)
return jsonify({"status": "ok", "marked": count})
+17 -4
View File
@@ -26,6 +26,7 @@ from fabledassistant.services.notes import (
get_backlinks,
get_note,
get_note_by_title,
get_note_for_user,
get_or_create_note_by_title,
list_notes,
update_note,
@@ -61,6 +62,7 @@ async def list_notes_route():
project_id = request.args.get("project_id", type=int)
milestone_id = request.args.get("milestone_id", type=int)
parent_id = request.args.get("parent_id", type=int)
no_project = request.args.get("no_project", "").lower() == "true"
# type= shorthand used by web frontend (?type=task or ?type=note)
type_param = request.args.get("type")
@@ -73,6 +75,7 @@ async def list_notes_route():
uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order,
limit=limit, offset=offset,
project_id=project_id, milestone_id=milestone_id, parent_id=parent_id,
no_project=no_project,
)
return jsonify({"notes": [n.to_dict() for n in notes], "total": total})
@@ -92,13 +95,20 @@ async def create_note_route():
if isinstance(due_date, tuple):
return due_date
project_id = data.get("project_id")
if project_id is None and data.get("project"):
from fabledassistant.services.projects import get_project_by_title as _gpbt
proj = await _gpbt(uid, data["project"])
if proj:
project_id = proj.id
note = await create_note(
uid,
title=data.get("title", ""),
body=body,
tags=tags,
parent_id=data.get("parent_id"),
project_id=data.get("project_id"),
project_id=project_id,
milestone_id=data.get("milestone_id"),
status=status,
priority=priority,
@@ -180,10 +190,13 @@ async def resolve_title_route():
@login_required
async def get_note_route(note_id: int):
uid = get_current_user_id()
note = await get_note(uid, note_id)
if note is None:
result = await get_note_for_user(uid, note_id)
if result is None:
return not_found("Note")
return jsonify(note.to_dict())
note, permission = result
data = note.to_dict()
data["permission"] = permission
return jsonify(data)
@notes_bp.route("/<int:note_id>", methods=["PUT"])
+14 -8
View File
@@ -11,8 +11,9 @@ from fabledassistant.services.projects import (
create_project,
delete_project,
get_project,
get_project_for_user,
get_project_summary,
list_projects,
list_projects_for_user,
update_project,
)
@@ -26,8 +27,8 @@ projects_bp = Blueprint("projects", __name__, url_prefix="/api/projects")
async def list_projects_route():
uid = get_current_user_id()
status = request.args.get("status")
projects = await list_projects(uid, status=status)
return jsonify({"projects": [p.to_dict() for p in projects]})
projects = await list_projects_for_user(uid, status=status)
return jsonify({"projects": projects})
@projects_bp.route("", methods=["POST"])
@@ -51,12 +52,16 @@ async def create_project_route():
@login_required
async def get_project_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
summary = await get_project_summary(uid, project_id)
project, permission = result
# Summary uses the project owner's uid for stats when viewer is not the owner
owner_uid = project.user_id or uid
summary = await get_project_summary(owner_uid, project_id)
data = project.to_dict()
data["summary"] = summary
data["permission"] = permission
return jsonify(data)
@@ -87,9 +92,10 @@ async def delete_project_route(project_id: int):
@login_required
async def get_project_notes_route(project_id: int):
uid = get_current_user_id()
project = await get_project(uid, project_id)
if project is None:
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, _ = result
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
+128
View File
@@ -0,0 +1,128 @@
import asyncio
from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.services import sharing as share_svc
from fabledassistant.services.access import can_admin_project, can_write_note
from fabledassistant.services.notifications import notify_note_shared, notify_project_shared
from fabledassistant.services.sharing import list_shared_with_me
shares_bp = Blueprint("shares", __name__)
# ---------------------------------------------------------------------------
# Project shares
# ---------------------------------------------------------------------------
@shares_bp.route("/api/projects/<int:project_id>/shares", methods=["GET"])
@login_required
async def list_project_shares(project_id: int):
uid = get_current_user_id()
if not await can_admin_project(uid, project_id):
return jsonify({"error": "Forbidden"}), 403
return jsonify({"shares": await share_svc.list_project_shares(project_id)})
@shares_bp.route("/api/projects/<int:project_id>/shares", methods=["POST"])
@login_required
async def create_project_share(project_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.share_project(
uid, project_id,
target_user_id=data.get("user_id"),
target_group_id=data.get("group_id"),
permission=data.get("permission", "viewer"),
)
if not share:
return jsonify({"error": "Forbidden or invalid permission"}), 403
asyncio.create_task(notify_project_shared(
project_id, share.permission, uid,
data.get("user_id"), data.get("group_id"),
))
return jsonify(share.to_dict()), 201
@shares_bp.route("/api/projects/<int:project_id>/shares/<int:share_id>", methods=["PATCH"])
@login_required
async def update_project_share(project_id: int, share_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.update_project_share(uid, share_id, data.get("permission", ""))
if not share:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(share.to_dict())
@shares_bp.route("/api/projects/<int:project_id>/shares/<int:share_id>", methods=["DELETE"])
@login_required
async def remove_project_share(project_id: int, share_id: int):
uid = get_current_user_id()
if not await share_svc.remove_project_share(uid, share_id):
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
# ---------------------------------------------------------------------------
# Note / task shares
# ---------------------------------------------------------------------------
@shares_bp.route("/api/notes/<int:note_id>/shares", methods=["GET"])
@login_required
async def list_note_shares(note_id: int):
uid = get_current_user_id()
if not await can_write_note(uid, note_id):
return jsonify({"error": "Forbidden"}), 403
return jsonify({"shares": await share_svc.list_note_shares(note_id)})
@shares_bp.route("/api/notes/<int:note_id>/shares", methods=["POST"])
@login_required
async def create_note_share(note_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.share_note(
uid, note_id,
target_user_id=data.get("user_id"),
target_group_id=data.get("group_id"),
permission=data.get("permission", "viewer"),
)
if not share:
return jsonify({"error": "Forbidden or invalid permission"}), 403
asyncio.create_task(notify_note_shared(
note_id, share.permission, uid,
data.get("user_id"), data.get("group_id"),
))
return jsonify(share.to_dict()), 201
@shares_bp.route("/api/notes/<int:note_id>/shares/<int:share_id>", methods=["PATCH"])
@login_required
async def update_note_share(note_id: int, share_id: int):
uid = get_current_user_id()
data = await request.get_json()
share = await share_svc.update_note_share(uid, share_id, data.get("permission", ""))
if not share:
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify(share.to_dict())
@shares_bp.route("/api/notes/<int:note_id>/shares/<int:share_id>", methods=["DELETE"])
@login_required
async def remove_note_share(note_id: int, share_id: int):
uid = get_current_user_id()
if not await share_svc.remove_note_share(uid, share_id):
return jsonify({"error": "Not found or forbidden"}), 404
return jsonify({"status": "ok"})
# ---------------------------------------------------------------------------
# Shared-with-me
# ---------------------------------------------------------------------------
@shares_bp.route("/api/shared-with-me", methods=["GET"])
@login_required
async def shared_with_me():
uid = get_current_user_id()
return jsonify(await list_shared_with_me(uid))
+1 -1
View File
@@ -16,7 +16,7 @@ task_logs_bp = Blueprint("task_logs", __name__, url_prefix="/api/tasks")
async def list_logs_route(task_id: int):
uid = get_current_user_id()
logs = await list_logs(uid, task_id)
return jsonify({"logs": [l.to_dict() for l in logs]})
return jsonify({"logs": [log.to_dict() for log in logs]})
@task_logs_bp.route("/<int:task_id>/logs", methods=["POST"])
+18 -3
View File
@@ -7,6 +7,7 @@ from fabledassistant.services.notes import (
create_note,
delete_note,
get_note,
get_note_for_user,
list_notes,
update_note,
)
@@ -27,6 +28,9 @@ async def list_tasks_route():
limit = min(request.args.get("limit", 50, type=int), 500)
offset = request.args.get("offset", 0, type=int)
project_id = request.args.get("project_id", type=int)
no_project = request.args.get("no_project", "").lower() == "true"
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
if isinstance(due_before, tuple):
return due_before
@@ -43,6 +47,8 @@ async def list_tasks_route():
priority=priority,
due_before=due_before,
due_after=due_after,
project_id=project_id,
no_project=no_project,
sort=sort,
order=order,
limit=limit,
@@ -68,6 +74,13 @@ async def create_task_route():
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
)
project_id = data.get("project_id")
if project_id is None and data.get("project"):
from fabledassistant.services.projects import get_project_by_title as _gpbt
proj = await _gpbt(uid, data["project"])
if proj:
project_id = proj.id
task = await create_note(
uid,
title=data.get("title", ""),
@@ -76,7 +89,7 @@ async def create_task_route():
priority=priority,
due_date=due_date,
tags=tags,
project_id=data.get("project_id"),
project_id=project_id,
milestone_id=data.get("milestone_id"),
parent_id=data.get("parent_id"),
)
@@ -87,10 +100,12 @@ async def create_task_route():
@login_required
async def get_task_route(task_id: int):
uid = get_current_user_id()
task = await get_note(uid, task_id)
if task is None:
result = await get_note_for_user(uid, task_id)
if result is None:
return not_found("Task")
task, permission = result
data = task.to_dict()
data["permission"] = permission
if task.parent_id:
parent = await get_note(uid, task.parent_id)
data["parent_title"] = parent.title if parent else None
+29
View File
@@ -0,0 +1,29 @@
from quart import Blueprint, jsonify, request
from sqlalchemy import or_, select
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.models import async_session
from fabledassistant.models.user import User
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
@users_bp.route("/search", methods=["GET"])
@login_required
async def search_users():
uid = get_current_user_id()
q = (request.args.get("q") or "").strip()
if len(q) < 2:
return jsonify({"users": []})
like = f"{q}%"
async with async_session() as session:
users = (await session.execute(
select(User).where(
User.id != uid,
or_(User.username.ilike(like), User.email.ilike(like)),
).limit(10)
)).scalars().all()
return jsonify({"users": [
{"id": u.id, "username": u.username, "email": u.email}
for u in users
]})
+163
View File
@@ -0,0 +1,163 @@
"""Access control service.
Single source of truth for permission resolution on projects and notes.
All human-facing routes that should honour shares call these functions.
LLM tool routes continue to use owner-scoped service functions directly.
Permission rank: owner > admin > editor > viewer
"""
import logging
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.group import GroupMembership
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
from fabledassistant.models.share import NoteShare, ProjectShare
logger = logging.getLogger(__name__)
PERMISSION_RANK: dict[str, int] = {
"viewer": 1,
"editor": 2,
"admin": 3,
"owner": 4,
}
def _higher(a: str | None, b: str | None) -> str | None:
"""Return the higher-ranked permission, or None if both are None."""
if a is None:
return b
if b is None:
return a
return a if PERMISSION_RANK[a] >= PERMISSION_RANK[b] else b
async def _user_group_ids(session, user_id: int) -> list[int]:
rows = (
await session.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)
).scalars().all()
return list(rows)
# ---------------------------------------------------------------------------
# Project permissions
# ---------------------------------------------------------------------------
async def get_project_permission(user_id: int, project_id: int) -> str | None:
"""Return the effective permission string for user on project, or None."""
async with async_session() as session:
project = await session.get(Project, project_id)
if project is None:
return None
if project.user_id == user_id:
return "owner"
# Direct share
direct = (
await session.execute(
select(ProjectShare).where(
ProjectShare.project_id == project_id,
ProjectShare.shared_with_user_id == user_id,
)
)
).scalar_one_or_none()
# Group shares
group_ids = await _user_group_ids(session, user_id)
group_perm: str | None = None
if group_ids:
group_shares = (
await session.execute(
select(ProjectShare).where(
ProjectShare.project_id == project_id,
ProjectShare.shared_with_group_id.in_(group_ids),
)
)
).scalars().all()
for gs in group_shares:
group_perm = _higher(group_perm, gs.permission)
return _higher(direct.permission if direct else None, group_perm)
async def can_read_project(user_id: int, project_id: int) -> bool:
return (await get_project_permission(user_id, project_id)) is not None
async def can_write_project(user_id: int, project_id: int) -> bool:
perm = await get_project_permission(user_id, project_id)
return perm in ("editor", "admin", "owner")
async def can_admin_project(user_id: int, project_id: int) -> bool:
perm = await get_project_permission(user_id, project_id)
return perm in ("admin", "owner")
# ---------------------------------------------------------------------------
# Note / task permissions
# ---------------------------------------------------------------------------
async def get_note_permission(user_id: int, note_id: int) -> str | None:
"""Return the effective permission for user on a note/task, or None.
Resolution order:
1. Ownership
2. Direct note share
3. Group-based note share
4. Inherited from project share (if note belongs to a project)
Highest rank wins.
"""
async with async_session() as session:
note = await session.get(Note, note_id)
if note is None:
return None
if note.user_id == user_id:
return "owner"
direct = (
await session.execute(
select(NoteShare).where(
NoteShare.note_id == note_id,
NoteShare.shared_with_user_id == user_id,
)
)
).scalar_one_or_none()
group_ids = await _user_group_ids(session, user_id)
group_perm: str | None = None
if group_ids:
group_shares = (
await session.execute(
select(NoteShare).where(
NoteShare.note_id == note_id,
NoteShare.shared_with_group_id.in_(group_ids),
)
)
).scalars().all()
for gs in group_shares:
group_perm = _higher(group_perm, gs.permission)
note_perm = _higher(direct.permission if direct else None, group_perm)
# Inherit from project if note belongs to one
if note.project_id is not None:
project_perm = await get_project_permission(user_id, note.project_id)
note_perm = _higher(note_perm, project_perm)
return note_perm
async def can_read_note(user_id: int, note_id: int) -> bool:
return (await get_note_permission(user_id, note_id)) is not None
async def can_write_note(user_id: int, note_id: int) -> bool:
perm = await get_note_permission(user_id, note_id)
return perm in ("editor", "admin", "owner")
+555 -162
View File
@@ -1,175 +1,369 @@
import logging
from datetime import date, datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.models.milestone import Milestone
from fabledassistant.models.note import Note
from fabledassistant.models.note_draft import NoteDraft
from fabledassistant.models.note_version import NoteVersion
from fabledassistant.models.project import Project
from fabledassistant.models.push_subscription import PushSubscription
from fabledassistant.models.setting import Setting
from fabledassistant.models.task_log import TaskLog
from fabledassistant.models.user import User
logger = logging.getLogger(__name__)
def _dt(val: str | None) -> datetime:
return datetime.fromisoformat(val) if val else datetime.now(timezone.utc)
def _d(val: str | None) -> date | None:
return date.fromisoformat(val) if val else None
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
async def export_full_backup() -> dict:
"""Export all users, notes, conversations+messages, and settings as JSON."""
"""Export all data as version-2 JSON backup."""
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()
conversations = (
await session.execute(
select(Conversation).options(selectinload(Conversation.messages))
)
).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.id)
)).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": 1,
"scope": "full",
"_security_notice": (
"This backup contains hashed passwords. "
"Store it securely and restrict access."
),
"users": [
{
"id": u.id,
"username": u.username,
"email": u.email,
"password_hash": u.password_hash,
"role": u.role,
"created_at": u.created_at.isoformat(),
}
for u in users
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"conversations": [
{
"id": c.id,
"user_id": c.user_id,
"title": c.title,
"model": c.model,
"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
],
}
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,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"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,
"task_id": tl.task_id,
"content": tl.content,
"duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"user_id": nd.user_id,
"note_id": nd.note_id,
"proposed_body": nd.proposed_body,
"original_body": nd.original_body,
"instruction": nd.instruction,
"scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
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,
"tags": nv.tags or [],
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"conversations": [
{
"id": c.id,
"user_id": c.user_id,
"title": c.title,
"model": c.model,
"created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(),
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"status": m.status,
"context_note_id": m.context_note_id,
"tool_calls": m.tool_calls,
"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,
"user_agent": ps.user_agent,
"created_at": ps.created_at.isoformat(),
}
for ps in push_subs
],
}
async def export_user_backup(user_id: int) -> dict:
"""Export a single user's data as JSON."""
"""Export a single user's data as version-2 JSON backup."""
async with async_session() as session:
user = await session.get(User, user_id)
notes = (
await session.execute(select(Note).where(Note.user_id == user_id))
).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()
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.id)
)).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": 1,
"scope": "user",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"created_at": user.created_at.isoformat(),
} if user else None,
"notes": [
{
"id": n.id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"conversations": [
{
"id": c.id,
"title": c.title,
"model": c.model,
"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": [
{"key": s.key, "value": s.value}
for s in settings
],
}
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": [
{
"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,
"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,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"task_id": tl.task_id,
"content": tl.content,
"duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"note_id": nd.note_id,
"proposed_body": nd.proposed_body,
"original_body": nd.original_body,
"instruction": nd.instruction,
"scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"tags": nv.tags or [],
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"conversations": [
{
"id": c.id,
"title": c.title,
"model": c.model,
"created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(),
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"status": m.status,
"context_note_id": m.context_note_id,
"tool_calls": m.tool_calls,
"created_at": m.created_at.isoformat(),
}
for m in c.messages
],
}
for c in conversations
],
"settings": [
{"key": s.key, "value": s.value}
for s in settings
],
}
# ---------------------------------------------------------------------------
# Restore
# ---------------------------------------------------------------------------
async def restore_full_backup(data: dict) -> dict:
"""Restore from a full backup JSON. Returns stats about what was restored."""
from datetime import date, datetime, timezone
"""Restore from backup JSON. Dispatches by version."""
version = data.get("version", 1)
if version == 1:
return await _restore_v1(data)
return await _restore_v2(data)
async def _restore_v1(data: dict) -> dict:
"""Restore legacy v1 backup (original format)."""
stats = {"users": 0, "notes": 0, "conversations": 0, "messages": 0, "settings": 0}
async with async_session() as session:
# Restore users
user_id_map: dict[int, int] = {}
for u_data in data.get("users", []):
old_id = u_data["id"]
@@ -178,34 +372,30 @@ async def restore_full_backup(data: dict) -> dict:
email=u_data.get("email"),
password_hash=u_data["password_hash"],
role=u_data.get("role", "user"),
created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc),
created_at=_dt(u_data.get("created_at")),
)
session.add(user)
await session.flush()
user_id_map[old_id] = user.id
stats["users"] += 1
# Restore notes
note_id_map: dict[int, int] = {}
for n_data in data.get("notes", []):
old_id = n_data.get("id")
mapped_user_id = user_id_map.get(n_data.get("user_id", 0))
if mapped_user_id is None:
continue
due = None
if n_data.get("due_date"):
due = date.fromisoformat(n_data["due_date"])
note = Note(
user_id=mapped_user_id,
title=n_data.get("title", ""),
body=n_data.get("body", ""),
tags=n_data.get("tags", []),
parent_id=n_data.get("parent_id"),
parent_id=None, # patched below
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),
due_date=_d(n_data.get("due_date")),
created_at=_dt(n_data.get("created_at")),
updated_at=_dt(n_data.get("updated_at")),
)
session.add(note)
await session.flush()
@@ -213,7 +403,15 @@ async def restore_full_backup(data: dict) -> dict:
note_id_map[old_id] = note.id
stats["notes"] += 1
# Restore conversations + messages
# Patch parent_id now that all notes have new IDs
for n_data in data.get("notes", []):
old_id = n_data.get("id")
old_parent = n_data.get("parent_id")
if old_id and old_parent and old_id in note_id_map and old_parent in note_id_map:
note_row = await session.get(Note, note_id_map[old_id])
if note_row:
note_row.parent_id = note_id_map[old_parent]
for c_data in data.get("conversations", []):
mapped_user_id = user_id_map.get(c_data.get("user_id", 0))
if mapped_user_id is None:
@@ -222,38 +420,233 @@ async def restore_full_backup(data: dict) -> dict:
user_id=mapped_user_id,
title=c_data.get("title", ""),
model=c_data.get("model", ""),
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),
created_at=_dt(c_data.get("created_at")),
updated_at=_dt(c_data.get("updated_at")),
)
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.get("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),
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
created_at=_dt(m_data.get("created_at")),
)
session.add(msg)
stats["messages"] += 1
# Restore settings
for s_data in data.get("settings", []):
mapped_user_id = user_id_map.get(s_data.get("user_id", 0))
if mapped_user_id is None:
continue
setting = Setting(
user_id=mapped_user_id,
key=s_data["key"],
value=s_data.get("value", ""),
)
session.add(setting)
session.add(Setting(user_id=mapped_user_id, key=s_data["key"], value=s_data.get("value", "")))
stats["settings"] += 1
await session.commit()
logger.info("Restored backup: %s", stats)
logger.info("Restored v1 backup: %s", stats)
return stats
async def _restore_v2(data: dict) -> dict:
"""Restore v2 backup with full FK re-mapping."""
stats: dict[str, int] = {
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
"task_logs": 0, "note_drafts": 0, "note_versions": 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=_dt(u_data.get("created_at")),
)
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=_dt(p_data.get("created_at")),
updated_at=_dt(p_data.get("updated_at")),
)
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 or mapped_pid 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", "active"),
order_index=m_data.get("order_index", 0),
created_at=_dt(m_data.get("created_at")),
updated_at=_dt(m_data.get("updated_at")),
)
session.add(ms)
await session.flush()
milestone_id_map[m_data["id"]] = ms.id
stats["milestones"] += 1
# 4a. Notes — first pass (no parent_id yet)
notes_with_parents: list[tuple[int, int]] = [] # (new_note_id, old_parent_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
note = Note(
user_id=mapped_uid,
title=n_data.get("title", ""),
body=n_data.get("body", ""),
tags=n_data.get("tags", []),
parent_id=None,
project_id=project_id_map.get(n_data["project_id"]) if n_data.get("project_id") else None,
milestone_id=milestone_id_map.get(n_data["milestone_id"]) if n_data.get("milestone_id") else None,
status=n_data.get("status"),
priority=n_data.get("priority"),
due_date=_d(n_data.get("due_date")),
created_at=_dt(n_data.get("created_at")),
updated_at=_dt(n_data.get("updated_at")),
)
session.add(note)
await session.flush()
note_id_map[n_data["id"]] = note.id
if n_data.get("parent_id"):
notes_with_parents.append((note.id, n_data["parent_id"]))
stats["notes"] += 1
# 4b. Patch parent_id
for new_note_id, old_parent_id in notes_with_parents:
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
# 5. TaskLogs
for tl_data in data.get("task_logs", []):
mapped_uid = user_id_map.get(tl_data.get("user_id", 0))
mapped_tid = note_id_map.get(tl_data.get("task_id", 0))
if mapped_uid is None or mapped_tid is None:
continue
tl = TaskLog(
user_id=mapped_uid,
task_id=mapped_tid,
content=tl_data.get("content", ""),
duration_minutes=tl_data.get("duration_minutes"),
created_at=_dt(tl_data.get("created_at")),
updated_at=_dt(tl_data.get("updated_at")),
)
session.add(tl)
stats["task_logs"] += 1
# 6. 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
nd = NoteDraft(
user_id=mapped_uid,
note_id=mapped_nid,
proposed_body=nd_data.get("proposed_body", ""),
original_body=nd_data.get("original_body", ""),
instruction=nd_data.get("instruction", ""),
scope=nd_data.get("scope", "document"),
created_at=_dt(nd_data.get("created_at")),
updated_at=_dt(nd_data.get("updated_at")),
)
session.add(nd)
stats["note_drafts"] += 1
# 7. NoteVersions
for nv_data in data.get("note_versions", []):
mapped_uid = user_id_map.get(nv_data.get("user_id", 0))
mapped_nid = note_id_map.get(nv_data.get("note_id", 0))
if mapped_uid is None or mapped_nid is None:
continue
nv = NoteVersion(
user_id=mapped_uid,
note_id=mapped_nid,
title=nv_data.get("title", ""),
body=nv_data.get("body", ""),
tags=nv_data.get("tags", []),
created_at=_dt(nv_data.get("created_at")),
)
session.add(nv)
stats["note_versions"] += 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", ""),
model=c_data.get("model", ""),
created_at=_dt(c_data.get("created_at")),
updated_at=_dt(c_data.get("updated_at")),
)
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", ""),
status=m_data.get("status", "complete"),
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
tool_calls=m_data.get("tool_calls"),
created_at=_dt(m_data.get("created_at")),
)
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
session.add(Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", "")))
stats["settings"] += 1
await session.commit()
logger.info("Restored v2 backup: %s", stats)
return stats
@@ -0,0 +1,74 @@
"""Create and manage briefing conversations."""
import logging
from datetime import date, datetime, timezone
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
logger = logging.getLogger(__name__)
async def get_or_create_today_conversation(user_id: int, model: str) -> Conversation:
"""
Return today's briefing conversation, creating it if it doesn't exist.
"""
today = date.today()
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "briefing",
Conversation.briefing_date == today,
)
)
conv = result.scalars().first()
if conv is not None:
return conv
conv = Conversation(
user_id=user_id,
title=f"Briefing — {today.isoformat()}",
model=model,
conversation_type="briefing",
briefing_date=today,
)
session.add(conv)
await session.commit()
await session.refresh(conv)
return conv
async def post_message(conversation_id: int, role: str, content: str) -> Message:
"""Append a message to a briefing conversation."""
async with async_session() as session:
msg = Message(
conversation_id=conversation_id,
role=role,
content=content,
status="complete",
)
session.add(msg)
# Bump conversation updated_at
conv = await session.get(Conversation, conversation_id)
if conv:
conv.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(msg)
return msg
async def list_briefing_conversations(user_id: int) -> list[dict]:
"""Return briefing conversations newest first."""
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "briefing",
).order_by(Conversation.briefing_date.desc().nullslast())
.limit(30)
)
convs = list(result.scalars().all())
return [c.to_dict() for c in convs]
@@ -0,0 +1,286 @@
"""
Briefing pipeline: parallel data gather + two-lane LLM synthesis.
Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (4pm)
"""
import asyncio
import logging
from datetime import date
import httpx
from fabledassistant.config import Config
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
def slot_greeting(slot: str) -> str:
return {
"compilation": "Good morning",
"morning": "Good morning — you're at the office",
"midday": "Midday check-in",
"afternoon": "End of day wrap-up",
}.get(slot, "Update")
def format_task(task: dict) -> str:
parts = [task.get("title", "Untitled")]
if task.get("status"):
parts.append(f"[{task['status'].replace('_', ' ').title()}]")
if task.get("due_date"):
parts.append(f"due {task['due_date']}")
if task.get("priority") and task["priority"] not in ("none", ""):
parts.append(f"priority: {task['priority']}")
return "".join(parts)
# ── Internal data gather ──────────────────────────────────────────────────────
async def _gather_internal(user_id: int) -> dict:
"""Collect tasks, calendar events, and project data."""
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import list_projects
from fabledassistant.services.caldav import is_caldav_configured, list_events
today = date.today().isoformat()
# Tasks: overdue, due today, high priority in-progress
try:
all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100)
all_tasks = [
{
"title": t.title,
"status": t.status,
"due_date": t.due_date.isoformat() if t.due_date else None,
"priority": t.priority,
}
for t in all_task_objs
]
overdue = [
format_task(t) for t in all_tasks
if t.get("due_date") and t["due_date"] < today and t.get("status") != "done"
]
due_today = [
format_task(t) for t in all_tasks
if t.get("due_date") == today and t.get("status") != "done"
]
high_priority = [
format_task(t) for t in all_tasks
if t.get("priority") == "high" and t.get("status") not in ("done",) and
format_task(t) not in overdue and format_task(t) not in due_today
][:5]
except Exception:
logger.warning("Failed to gather tasks for briefing", exc_info=True)
overdue, due_today, high_priority = [], [], []
# Calendar events today
calendar_events = []
try:
if is_caldav_configured():
events = await list_events(user_id, start=today, end=today)
calendar_events = [
f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
for e in (events or [])
]
except Exception:
logger.warning("Failed to gather calendar events for briefing", exc_info=True)
# Projects: active projects
projects_summary = []
try:
projects = await list_projects(user_id)
for p in projects[:5]:
projects_summary.append(p.get("title", "Untitled project"))
except Exception:
logger.warning("Failed to gather projects for briefing", exc_info=True)
return {
"date": today,
"overdue_tasks": overdue,
"due_today": due_today,
"high_priority": high_priority,
"calendar_events": calendar_events,
"active_projects": projects_summary,
}
# ── External data gather ──────────────────────────────────────────────────────
async def _gather_external(user_id: int) -> dict:
"""Collect RSS items and weather."""
from fabledassistant.services.rss import get_recent_items
from fabledassistant.services.weather import get_cached_weather
rss_items, weather = await asyncio.gather(
get_recent_items(user_id, limit=20),
get_cached_weather(user_id),
return_exceptions=True,
)
return {
"rss_items": rss_items if not isinstance(rss_items, Exception) else [],
"weather": weather if not isinstance(weather, Exception) else [],
}
# ── LLM synthesis ─────────────────────────────────────────────────────────────
async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str) -> str:
"""Single non-streaming LLM call. Returns the assistant's response text."""
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"stream": False,
"options": {"num_ctx": 4096, "temperature": 0.4},
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload)
resp.raise_for_status()
data = resp.json()
return data.get("message", {}).get("content", "").strip()
except Exception:
logger.warning("LLM synthesis failed", exc_info=True)
return ""
def _internal_system_prompt(profile_body: str) -> str:
return (
"You are a personal briefing assistant. Your job is to give the user a clear, "
"concise summary of their internal workload: tasks, calendar, and projects. "
"Be direct and prioritised — lead with what's urgent. Use plain text with light "
"markdown. Do not include weather or news.\n\n"
+ (f"User profile:\n{profile_body}\n" if profile_body else "")
)
def _external_system_prompt() -> str:
return (
"You are a briefing assistant for external information. Your job is to summarise "
"the user's RSS feed digest and weather forecast into a concise, engaging update. "
"Group related news items. Note any significant weather changes. "
"Be informative but brief. Do not discuss tasks, calendar, or work items."
)
def _internal_user_prompt(data: dict, slot: str) -> str:
lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""]
if data["overdue_tasks"]:
lines.append(f"OVERDUE ({len(data['overdue_tasks'])}):")
lines.extend(f" - {t}" for t in data["overdue_tasks"])
lines.append("")
if data["due_today"]:
lines.append(f"DUE TODAY ({len(data['due_today'])}):")
lines.extend(f" - {t}" for t in data["due_today"])
lines.append("")
if data["high_priority"]:
lines.append("HIGH PRIORITY (in progress):")
lines.extend(f" - {t}" for t in data["high_priority"])
lines.append("")
if data["calendar_events"]:
lines.append("CALENDAR TODAY:")
lines.extend(f" - {e}" for e in data["calendar_events"])
lines.append("")
if data["active_projects"]:
lines.append(f"ACTIVE PROJECTS: {', '.join(data['active_projects'])}")
return "\n".join(lines)
def _external_user_prompt(data: dict, slot: str) -> str:
lines = [f"Briefing slot: {slot}", ""]
if data["weather"]:
lines.append("WEATHER:")
for loc in data["weather"]:
lines.append(f" {loc['location_label']}:")
for day in loc["days"][:3]:
lines.append(
f" {day['date']}: {day['description']}, "
f"{day['temp_min']}{day['temp_max']}°C, {day['precip_mm']}mm rain"
)
if loc["changes_since_last_fetch"]:
lines.append(" FORECAST CHANGES:")
lines.extend(f" - {c}" for c in loc["changes_since_last_fetch"])
lines.append("")
if data["rss_items"]:
lines.append(f"RSS DIGEST ({len(data['rss_items'])} items):")
for item in data["rss_items"][:15]:
lines.append(f" [{item.get('feed_title', 'Feed')}] {item['title']}")
if item.get("content"):
lines.append(f" {item['content'][:200]}")
return "\n".join(lines)
# ── Main entry point ───────────────────────────────────────────────────────────
async def run_compilation(user_id: int, slot: str, model: str | None = None) -> str:
"""
Run the full two-lane briefing pipeline for a user and slot.
Returns the combined briefing text to be posted as the opening assistant message.
"""
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
from fabledassistant.services.briefing_profile import get_profile_body
profile_body = await get_profile_body(user_id)
# Parallel gather
internal_data, external_data = await asyncio.gather(
_gather_internal(user_id),
_gather_external(user_id),
)
# Two-lane LLM synthesis (both calls run concurrently)
internal_text, external_text = await asyncio.gather(
_llm_synthesise(
_internal_system_prompt(profile_body),
_internal_user_prompt(internal_data, slot),
model,
),
_llm_synthesise(
_external_system_prompt(),
_external_user_prompt(external_data, slot),
model,
),
)
greeting = slot_greeting(slot)
today = internal_data["date"]
parts = [f"**{greeting}{today}**", ""]
if internal_text:
parts += ["## Your Day", "", internal_text, ""]
if external_text:
parts += ["## The World", "", external_text]
return "\n".join(parts).strip()
async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str:
"""
Lighter update for 8am/12pm/4pm — gathers fresh data and produces a slot-specific
update prompt. Returns the text to inject as a new user→assistant exchange.
"""
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
internal_data, external_data = await asyncio.gather(
_gather_internal(user_id),
_gather_external(user_id),
)
system = (
f"You are a briefing assistant providing a {slot} update. Be brief — "
"the user has already seen the morning briefing. Focus on what's changed or new."
)
user_prompt = (
f"Slot: {slot}\n\n"
+ _internal_user_prompt(internal_data, slot)
+ "\n\n"
+ _external_user_prompt(external_data, slot)
)
return await _llm_synthesise(system, user_prompt, model)
@@ -0,0 +1,80 @@
"""Briefing profile note: stores learned user preferences for the briefing assistant."""
import logging
from fabledassistant.services.notes import create_note, list_notes, update_note
logger = logging.getLogger(__name__)
PROFILE_TAG = "briefing-profile"
PROFILE_TITLE = "Briefing Profile"
async def _find_profile_note(user_id: int) -> dict | None:
"""Find the user's briefing profile note by tag."""
notes, _total = await list_notes(user_id, tags=[PROFILE_TAG], limit=1)
if not notes:
return None
note = notes[0]
return {
"id": note.id,
"body": note.body or "",
"title": note.title,
}
async def get_profile_body(user_id: int) -> str:
"""Return the body of the briefing profile note, or '' if none exists."""
note = await _find_profile_note(user_id)
return note["body"] if note else ""
async def get_profile_note_id(user_id: int) -> int | None:
note = await _find_profile_note(user_id)
return note["id"] if note else None
async def ensure_profile_note(user_id: int) -> int:
"""
Get or create the briefing profile note.
Returns the note id.
"""
note = await _find_profile_note(user_id)
if note:
return note["id"]
created = await create_note(
user_id=user_id,
title=PROFILE_TITLE,
body=(
"# Briefing Profile\n\n"
"This note is maintained by the briefing assistant. "
"It stores your preferences, patterns, and work schedule.\n\n"
"## Work Schedule\n\n"
"Office days: (not yet configured)\n\n"
"## Locations\n\n"
"(configured via Settings → Briefing)\n\n"
"## Preferences\n\n"
"(the assistant will add observations here over time)\n"
),
tags=[PROFILE_TAG],
)
return created.id
async def append_observations(user_id: int, observations: str) -> None:
"""
Append the assistant's end-of-day observations to the profile note.
Creates the note if it doesn't exist.
"""
if not observations.strip():
return
note_id = await ensure_profile_note(user_id)
note = await _find_profile_note(user_id)
if not note:
return
current_body = note.get("body", "")
from datetime import date
date_str = date.today().isoformat()
new_body = current_body.rstrip() + f"\n\n## Observations — {date_str}\n\n{observations.strip()}\n"
await update_note(user_id, note_id, body=new_body)
logger.info("Briefing profile updated for user %d", user_id)
@@ -0,0 +1,265 @@
"""
APScheduler-based briefing scheduler.
Uses a background thread scheduler (not async) because APScheduler 3.x's
AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async functions
wrapped with asyncio.run_coroutine_threadsafe().
"""
import asyncio
import logging
from datetime import date, datetime, time, timedelta, timezone
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.setting import Setting
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
# Slot definitions: (name, hour, minute)
SLOTS = [
("compilation", 4, 0),
("morning", 8, 0),
("midday", 12, 0),
("afternoon", 16, 0),
]
async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
"""Return [(user_id, model)] for users with briefing enabled."""
import json
async with async_session() as session:
result = await session.execute(
select(Setting).where(Setting.key == "briefing_config")
)
rows = list(result.scalars().all())
enabled = []
for row in rows:
try:
config = json.loads(row.value) if row.value else {}
if config.get("enabled"):
enabled.append((row.user_id, "")) # model resolved per-user at runtime
except Exception:
pass
return enabled
async def _run_slot_for_user(user_id: int, slot: str) -> None:
"""Execute one slot job for one user."""
from fabledassistant.services.briefing_conversations import (
get_or_create_today_conversation, post_message
)
from fabledassistant.services.briefing_pipeline import run_compilation, run_slot_injection
from fabledassistant.services.settings import get_setting
from fabledassistant.config import Config
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
if slot == "compilation":
# Refresh external data first
try:
from fabledassistant.services.rss import refresh_all_feeds
import json
config_raw = await get_setting(user_id, "briefing_config", "{}")
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
await refresh_all_feeds(user_id)
# Refresh weather for configured locations
from fabledassistant.services import weather as wx
for key, loc in config.get("locations", {}).items():
if loc.get("lat") and loc.get("lon"):
await wx.refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True)
# Run previous day's profile close-out
await _run_profile_closeout(user_id, model)
# Create today's conversation and post opening message
conv = await get_or_create_today_conversation(user_id, model)
text = await run_compilation(user_id, slot, model)
if text:
await post_message(conv.id, "assistant", text)
else:
# Inject slot update into today's conversation
conv = await get_or_create_today_conversation(user_id, model)
text = await run_slot_injection(user_id, slot, model)
if text:
# Post as a system-injected user prompt + assistant response pair
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
await post_message(conv.id, "assistant", text)
# Send push notification
try:
from fabledassistant.services.push import send_push_notification
slot_labels = {
"compilation": "Morning briefing ready",
"morning": "Office briefing ready",
"midday": "Midday check-in",
"afternoon": "End of day wrap-up",
}
await send_push_notification(
user_id=user_id,
title="Briefing",
body=slot_labels.get(slot, "Briefing update"),
url="/briefing",
)
except Exception:
logger.debug("Push notification failed for briefing slot %s", slot, exc_info=True)
logger.info("Briefing slot '%s' completed for user %d", slot, user_id)
async def _run_profile_closeout(user_id: int, model: str) -> None:
"""
Read yesterday's briefing conversation, ask the LLM to extract preference
observations, and append them to the briefing profile note.
"""
from fabledassistant.services.briefing_profile import append_observations
from fabledassistant.services.briefing_pipeline import _llm_synthesise
from fabledassistant.models.conversation import Conversation, Message
yesterday = (date.today() - timedelta(days=1))
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "briefing",
Conversation.briefing_date == yesterday,
)
)
conv = result.scalars().first()
if not conv:
return
msgs_result = await session.execute(
select(Message).where(Message.conversation_id == conv.id).order_by(Message.created_at)
)
messages = list(msgs_result.scalars().all())
if len(messages) < 2:
return # Nothing interesting to learn from
transcript = "\n".join(
f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:]
)
system = (
"You are reviewing a day's briefing conversation to extract preference observations. "
"Identify any patterns, preferences, or schedule facts the user revealed. "
"Write 2-5 short bullet points. Be specific and factual. "
"Example: '- User skipped news about finance', '- Prefers weather for home location first'. "
"If nothing notable, output only: (nothing to note)"
)
observations = await _llm_synthesise(system, transcript, model)
if observations and "(nothing to note)" not in observations.lower():
await append_observations(user_id, observations)
def _run_slot_sync(slot: str, loop: asyncio.AbstractEventLoop) -> None:
"""Synchronous wrapper called by APScheduler's background thread."""
async def _job():
users = await _get_briefing_enabled_users()
for user_id, _ in users:
try:
await _run_slot_for_user(user_id, slot)
except Exception:
logger.exception("Briefing slot '%s' failed for user %d", slot, user_id)
future = asyncio.run_coroutine_threadsafe(_job(), loop)
try:
future.result(timeout=600) # 10 min max per slot run
except Exception:
logger.exception("Briefing slot '%s' job failed", slot)
async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
"""
On startup, check if any slot was missed within the last 24 hours.
Fire it once if so. Never backfill more than one slot per slot-name.
"""
now = datetime.now(timezone.utc)
today = now.date()
for slot_name, hour, minute in SLOTS:
slot_time = datetime.combine(today, time(hour, minute), tzinfo=timezone.utc)
if slot_time > now:
continue # Hasn't happened yet today
age = (now - slot_time).total_seconds()
if age > 86400:
continue # More than 24h ago — skip
# Check if we already have a message for this slot today
# Simple heuristic: if today's briefing conversation has messages posted
# after the slot time, consider it covered
users = await _get_briefing_enabled_users()
for user_id, _ in users:
async with async_session() as session:
from fabledassistant.models.conversation import Conversation, Message
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "briefing",
Conversation.briefing_date == today,
)
)
conv = result.scalars().first()
if conv:
msgs = await session.execute(
select(Message).where(
Message.conversation_id == conv.id,
Message.created_at >= slot_time,
).limit(1)
)
if msgs.scalars().first():
continue # Already covered
# Fire the missed slot
logger.info("Catching up missed briefing slot '%s' for user %d", slot_name, user_id)
try:
await _run_slot_for_user(user_id, slot_name)
except Exception:
logger.exception("Catch-up for slot '%s' user %d failed", slot_name, user_id)
def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
"""
Start the APScheduler background scheduler.
Must be called from the app's before_serving hook with the running event loop.
"""
global _scheduler
if _scheduler is not None:
return
_scheduler = BackgroundScheduler(timezone="UTC")
for slot_name, hour, minute in SLOTS:
_scheduler.add_job(
_run_slot_sync,
CronTrigger(hour=hour, minute=minute),
args=[slot_name, loop],
id=f"briefing_{slot_name}",
replace_existing=True,
misfire_grace_time=3600, # Fire up to 1h late rather than skip
)
_scheduler.start()
logger.info("Briefing scheduler started")
# Catch up missed slots in the background
asyncio.run_coroutine_threadsafe(_catchup_missed_slots(loop), loop)
def stop_briefing_scheduler() -> None:
global _scheduler
if _scheduler:
_scheduler.shutdown(wait=False)
_scheduler = None
+6 -3
View File
@@ -46,12 +46,13 @@ async def get_conversation(
async def list_conversations(
user_id: int, limit: int = 50, offset: int = 0
user_id: int, limit: int = 50, offset: int = 0, conv_type: str = "chat"
) -> tuple[list[dict], int]:
async with async_session() as session:
total = await session.scalar(
select(func.count(Conversation.id)).where(
Conversation.user_id == user_id
Conversation.user_id == user_id,
Conversation.conversation_type == conv_type,
)
) or 0
@@ -65,7 +66,7 @@ async def list_conversations(
result = await session.execute(
select(Conversation, msg_count.label("message_count"))
.where(Conversation.user_id == user_id)
.where(Conversation.user_id == user_id, Conversation.conversation_type == conv_type)
.order_by(Conversation.updated_at.desc())
.limit(limit)
.offset(offset)
@@ -78,6 +79,8 @@ async def list_conversations(
"id": conv.id,
"title": conv.title,
"model": conv.model,
"conversation_type": conv.conversation_type,
"briefing_date": conv.briefing_date.isoformat() if conv.briefing_date else None,
"message_count": row[1],
"created_at": conv.created_at.isoformat(),
"updated_at": conv.updated_at.isoformat(),
@@ -80,6 +80,7 @@ async def semantic_search_notes(
limit: int = 8,
threshold: float = _SIMILARITY_THRESHOLD,
project_id: int | None = None,
is_task: bool | None = None,
) -> list[tuple[float, Note]]:
"""Return up to *limit* (score, note) pairs most relevant to *query*.
@@ -102,6 +103,10 @@ async def semantic_search_notes(
)
if project_id is not None:
stmt = stmt.where(Note.project_id == project_id)
if is_task is True:
stmt = stmt.where(Note.status.isnot(None))
elif is_task is False:
stmt = stmt.where(Note.status.is_(None))
if exclude_ids:
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
rows = list((await session.execute(stmt)).all())
@@ -260,7 +260,7 @@ async def run_generation(
if tool_name == "research_topic":
topic = arguments.get("topic", "")
try:
note = await run_research_pipeline(topic, user_id, model, buf)
note = await run_research_pipeline(topic, user_id, model, buf, project_id=workspace_project_id)
result = {
"success": True,
"type": "research_note",
@@ -358,9 +358,18 @@ async def run_generation(
try:
from fabledassistant.services.push import send_push_notification, vapid_enabled
if vapid_enabled():
preview = buf.content_so_far[:120].rstrip()
if len(buf.content_so_far) > 120:
preview += ""
text = buf.content_so_far.strip()
if text:
preview = text[:120].rstrip()
if len(text) > 120:
preview += ""
else:
# Tool-only response — summarise what was done
tool_names = [tc.get("function") for tc in all_tool_calls if tc.get("function")]
if tool_names:
preview = f"Completed: {', '.join(tool_names[:3])}"
else:
preview = "Action completed"
asyncio.create_task(send_push_notification(
user_id,
title="Response ready",
@@ -368,7 +377,7 @@ async def run_generation(
url=f"/chat/{conv_id}",
))
except Exception:
logger.debug("Failed to schedule push notification", exc_info=True)
logger.warning("Failed to schedule push notification", exc_info=True)
# Title generation is non-critical — fire-and-forget so done fires immediately
non_system = [m for m in messages if m["role"] != "system"]
+186
View File
@@ -0,0 +1,186 @@
"""Group management service."""
import logging
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from fabledassistant.models import async_session
from fabledassistant.models.group import Group, GroupMembership
from fabledassistant.models.user import User
logger = logging.getLogger(__name__)
async def create_group(
user_id: int, name: str, description: str | None = None
) -> Group:
async with async_session() as session:
group = Group(name=name, description=description, created_by=user_id)
session.add(group)
await session.flush()
session.add(GroupMembership(group_id=group.id, user_id=user_id, role="owner"))
await session.commit()
await session.refresh(group)
return group
async def list_groups(user_id: int) -> list[dict]:
"""All users see all groups with member count and their own membership status."""
async with async_session() as session:
groups = (await session.execute(select(Group).order_by(Group.name))).scalars().all()
user_group_ids = set(
(await session.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)).scalars().all()
)
result = []
for g in groups:
count = len(
(await session.execute(
select(GroupMembership).where(GroupMembership.group_id == g.id)
)).scalars().all()
)
d = g.to_dict()
d["member_count"] = count
d["is_member"] = g.id in user_group_ids
result.append(d)
return result
async def get_group(group_id: int) -> Group | None:
async with async_session() as session:
return await session.get(Group, group_id)
async def _is_group_owner(session, acting_user_id: int, group_id: int) -> bool:
m = (await session.execute(
select(GroupMembership).where(
GroupMembership.group_id == group_id,
GroupMembership.user_id == acting_user_id,
GroupMembership.role == "owner",
)
)).scalar_one_or_none()
return m is not None
async def update_group(
acting_user_id: int, group_id: int, is_site_admin: bool, **fields
) -> Group | None:
async with async_session() as session:
group = await session.get(Group, group_id)
if not group:
return None
if not is_site_admin and not await _is_group_owner(session, acting_user_id, group_id):
return None
for k, v in fields.items():
if hasattr(group, k):
setattr(group, k, v)
await session.commit()
await session.refresh(group)
return group
async def delete_group(acting_user_id: int, group_id: int, is_site_admin: bool) -> bool:
async with async_session() as session:
group = await session.get(Group, group_id)
if not group:
return False
if not is_site_admin and group.created_by != acting_user_id:
return False
await session.delete(group)
await session.commit()
return True
async def list_members(group_id: int) -> list[dict]:
async with async_session() as session:
rows = (await session.execute(
select(GroupMembership, User)
.join(User, User.id == GroupMembership.user_id)
.where(GroupMembership.group_id == group_id)
)).all()
return [
{**m.to_dict(), "username": u.username, "email": u.email}
for m, u in rows
]
async def add_member(
acting_user_id: int,
group_id: int,
target_user_id: int,
role: str,
is_site_admin: bool,
) -> GroupMembership | None:
async with async_session() as session:
if not is_site_admin and not await _is_group_owner(session, acting_user_id, group_id):
return None
membership = GroupMembership(group_id=group_id, user_id=target_user_id, role=role)
session.add(membership)
try:
await session.commit()
except IntegrityError:
await session.rollback()
return None
await session.refresh(membership)
return membership
async def update_member_role(
acting_user_id: int,
group_id: int,
target_user_id: int,
role: str,
is_site_admin: bool,
) -> GroupMembership | None:
async with async_session() as session:
if not is_site_admin and not await _is_group_owner(session, acting_user_id, group_id):
return None
m = (await session.execute(
select(GroupMembership).where(
GroupMembership.group_id == group_id,
GroupMembership.user_id == target_user_id,
)
)).scalar_one_or_none()
if not m:
return None
m.role = role
await session.commit()
await session.refresh(m)
return m
async def remove_member(
acting_user_id: int,
group_id: int,
target_user_id: int,
is_site_admin: bool,
) -> bool:
"""Group owner, site admin, or self-removal are all permitted."""
async with async_session() as session:
is_self = acting_user_id == target_user_id
if not is_site_admin and not is_self:
if not await _is_group_owner(session, acting_user_id, group_id):
return False
m = (await session.execute(
select(GroupMembership).where(
GroupMembership.group_id == group_id,
GroupMembership.user_id == target_user_id,
)
)).scalar_one_or_none()
if not m:
return False
await session.delete(m)
await session.commit()
return True
async def get_user_groups(user_id: int) -> list[Group]:
async with async_session() as session:
rows = (await session.execute(
select(Group)
.join(GroupMembership, GroupMembership.group_id == Group.id)
.where(GroupMembership.user_id == user_id)
)).scalars().all()
return list(rows)
+34 -2
View File
@@ -1,15 +1,47 @@
from datetime import datetime, timezone
from sqlalchemy import select, text
from fabledassistant.models import async_session
from fabledassistant.models.note_version import NoteVersion
MAX_VERSIONS = 20
# Maximum snapshots retained per note.
MAX_VERSIONS = 50
# Minimum seconds between snapshots. Prevents autosave from consuming all
# slots — with autosave at 60 s intervals, a 5-minute gate means at most
# one snapshot per editing session segment rather than one per save tick.
MIN_VERSION_INTERVAL_SECONDS = 300
async def create_version(
user_id: int, note_id: int, body: str, title: str, tags: list[str] | None = None
) -> NoteVersion:
) -> NoteVersion | None:
async with async_session() as session:
# Fetch the most recent snapshot once for both checks.
recent = await session.execute(
select(NoteVersion)
.where(NoteVersion.note_id == note_id, NoteVersion.user_id == user_id)
.order_by(NoteVersion.created_at.desc())
.limit(1)
)
last = recent.scalars().first()
if last is not None:
# Skip if content is identical — no change, no snapshot (git semantics).
if (
last.body == body
and last.title == title
and sorted(last.tags or []) == sorted(tags or [])
):
return None
# Skip if within the minimum interval to prevent rapid-fire autosave snapshots.
last_at = last.created_at
if last_at.tzinfo is None:
last_at = last_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - last_at).total_seconds()
if age < MIN_VERSION_INTERVAL_SECONDS:
return None
version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title, tags=tags or [])
session.add(version)
await session.commit()
+22
View File
@@ -85,6 +85,7 @@ async def list_notes(
milestone_id: int | None = None,
milestone_ids: list[int] | None = None,
parent_id: int | None = None,
no_project: bool = False,
sort: str = "updated_at",
order: str = "desc",
limit: int = 50,
@@ -152,6 +153,10 @@ async def list_notes(
if parent_id is not None:
query = query.where(Note.parent_id == parent_id)
if no_project:
query = query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.parent_id == parent_id)
sort_col = getattr(Note, sort, Note.updated_at)
@@ -436,3 +441,20 @@ async def build_note_graph(
edges.append({"source": nid, "target": tag_node_id, "type": "tag"})
return {"nodes": nodes, "edges": edges}
# ---------------------------------------------------------------------------
# Shared-access variant
# ---------------------------------------------------------------------------
async def get_note_for_user(
accessing_user_id: int, note_id: int
) -> tuple["Note", str] | None:
"""Returns (note, permission) if user has any access, else None."""
from fabledassistant.services.access import get_note_permission
perm = await get_note_permission(accessing_user_id, note_id)
if perm is None:
return None
async with async_session() as session:
note = await session.get(Note, note_id)
return (note, perm) if note else None
@@ -249,3 +249,199 @@ def start_notification_loop() -> None:
global _notification_task
if _notification_task is None or _notification_task.done():
_notification_task = asyncio.create_task(_notification_loop())
# ---------------------------------------------------------------------------
# In-app notifications (Notification model — shares, group events)
# ---------------------------------------------------------------------------
async def create_in_app_notification(user_id: int, notif_type: str, payload: dict):
"""Create an in-app Notification record."""
from fabledassistant.models.notification import Notification
async with async_session() as session:
n = Notification(user_id=user_id, type=notif_type, payload=payload)
session.add(n)
await session.commit()
await session.refresh(n)
return n
async def _fire_push_notif(user_id: int, title: str, body: str, url: str) -> None:
try:
from fabledassistant.services.push import send_push_notification
await send_push_notification(user_id, title, body, url=url)
except Exception:
logger.exception("Push notification failed for user %d", user_id)
async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
try:
if not await is_smtp_configured():
return
async with async_session() as session:
user = await session.get(User, user_id)
if user and user.email:
html = _email_html(subject, f"<p>{body_text.replace(chr(10), '<br>')}</p>")
await send_email(user.email, subject, html)
except Exception:
logger.exception("Share email notification failed for user %d", user_id)
async def _group_member_ids(group_id: int) -> list[int]:
from fabledassistant.models.group import GroupMembership
async with async_session() as session:
rows = (await session.execute(
select(GroupMembership.user_id).where(GroupMembership.group_id == group_id)
)).scalars().all()
return list(rows)
async def notify_project_shared(
project_id: int,
permission: str,
invited_by_user_id: int,
target_user_id: int | None,
target_group_id: int | None,
) -> None:
from fabledassistant.models.project import Project
async with async_session() as session:
project = await session.get(Project, project_id)
inviter = await session.get(User, invited_by_user_id)
if not project or not inviter:
return
recipients: list[int] = []
if target_user_id:
recipients = [target_user_id]
elif target_group_id:
recipients = [uid for uid in await _group_member_ids(target_group_id) if uid != invited_by_user_id]
url = f"/projects/{project_id}"
msg = f"{inviter.username} shared \"{project.title}\" with you as {permission}"
for uid in recipients:
await create_in_app_notification(uid, "project_shared", {
"project_id": project_id,
"project_title": project.title,
"permission": permission,
"invited_by": inviter.username,
"url": url,
})
asyncio.create_task(_fire_push_notif(uid, "Project shared with you", msg, url))
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a project with you", msg))
async def notify_note_shared(
note_id: int,
permission: str,
invited_by_user_id: int,
target_user_id: int | None,
target_group_id: int | None,
) -> None:
from fabledassistant.models.note import Note
async with async_session() as session:
note = await session.get(Note, note_id)
inviter = await session.get(User, invited_by_user_id)
if not note or not inviter:
return
recipients: list[int] = []
if target_user_id:
recipients = [target_user_id]
elif target_group_id:
recipients = [uid for uid in await _group_member_ids(target_group_id) if uid != invited_by_user_id]
route = "tasks" if note.is_task else "notes"
url = f"/{route}/{note_id}"
msg = f"{inviter.username} shared \"{note.title}\" with you as {permission}"
for uid in recipients:
await create_in_app_notification(uid, "note_shared", {
"note_id": note_id,
"note_title": note.title,
"permission": permission,
"invited_by": inviter.username,
"url": url,
})
asyncio.create_task(_fire_push_notif(uid, "Note shared with you", msg, url))
asyncio.create_task(_fire_share_email(uid, f"[Fabled] {inviter.username} shared a note with you", msg))
async def notify_group_added(
group_id: int, role: str, invited_by_user_id: int, target_user_id: int
) -> None:
from fabledassistant.models.group import Group
async with async_session() as session:
group = await session.get(Group, group_id)
inviter = await session.get(User, invited_by_user_id)
if not group or not inviter:
return
url = f"/groups/{group_id}"
msg = f"{inviter.username} added you to group \"{group.name}\" as {role}"
await create_in_app_notification(target_user_id, "group_added", {
"group_id": group_id,
"group_name": group.name,
"role": role,
"invited_by": inviter.username,
"url": url,
})
asyncio.create_task(_fire_push_notif(target_user_id, "Added to a group", msg, url))
asyncio.create_task(_fire_share_email(target_user_id, "[Fabled] You've been added to a group", msg))
async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> list[dict]:
from fabledassistant.models.notification import Notification
async with async_session() as session:
q = select(Notification).where(Notification.user_id == user_id)
if unread_only:
q = q.where(Notification.read_at.is_(None))
q = q.order_by(Notification.created_at.desc()).limit(50)
rows = (await session.execute(q)).scalars().all()
return [n.to_dict() for n in rows]
async def unread_notification_count(user_id: int) -> int:
from fabledassistant.models.notification import Notification
async with async_session() as session:
result = await session.execute(
select(func.count()).where(
Notification.user_id == user_id,
Notification.read_at.is_(None),
)
)
return result.scalar() or 0
async def mark_notification_read(user_id: int, notification_id: int) -> bool:
from fabledassistant.models.notification import Notification
from datetime import timezone as tz
async with async_session() as session:
n = (await session.execute(
select(Notification).where(
Notification.id == notification_id,
Notification.user_id == user_id,
)
)).scalar_one_or_none()
if not n:
return False
from datetime import datetime
n.read_at = datetime.now(tz.utc)
await session.commit()
return True
async def mark_all_notifications_read(user_id: int) -> int:
from fabledassistant.models.notification import Notification
from datetime import datetime, timezone as tz
from sqlalchemy import update as sa_update
async with async_session() as session:
result = await session.execute(
sa_update(Notification)
.where(Notification.user_id == user_id, Notification.read_at.is_(None))
.values(read_at=datetime.now(tz.utc))
.returning(Notification.id)
)
await session.commit()
return len(result.fetchall())
+75
View File
@@ -144,3 +144,78 @@ async def get_project_summary(user_id: int, project_id: int) -> dict:
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
"milestone_summary": milestone_summary,
}
# ---------------------------------------------------------------------------
# Shared-access variants (honour ProjectShare in addition to ownership)
# ---------------------------------------------------------------------------
async def get_project_for_user(
accessing_user_id: int, project_id: int
) -> tuple[Project, str] | None:
"""Returns (project, permission) if user has any access, else None."""
from fabledassistant.services.access import get_project_permission
perm = await get_project_permission(accessing_user_id, project_id)
if perm is None:
return None
async with async_session() as session:
project = await session.get(Project, project_id)
return (project, perm) if project else None
async def list_projects_for_user(user_id: int, status: str | None = None) -> list[dict]:
"""Owned projects + shared projects, each dict has 'permission' field."""
from fabledassistant.models.group import GroupMembership
from fabledassistant.models.share import ProjectShare
from fabledassistant.services.access import PERMISSION_RANK
owned = await list_projects(user_id, status)
owned_ids = {p.id for p in owned}
result = []
for p in owned:
d = p.to_dict() if hasattr(p, "to_dict") else {"id": p.id, "title": p.title}
d["permission"] = "owner"
result.append(d)
async with async_session() as session:
user_group_ids = (await session.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)).scalars().all()
shared_direct = (await session.execute(
select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id)
)).scalars().all()
shared_group: list[ProjectShare] = []
if user_group_ids:
shared_group = (await session.execute(
select(ProjectShare).where(
ProjectShare.shared_with_group_id.in_(user_group_ids)
)
)).scalars().all()
seen: dict[int, str] = {}
for share in list(shared_direct) + list(shared_group):
if share.project_id in owned_ids:
continue
prev = seen.get(share.project_id)
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen[share.project_id] = share.permission
for pid, perm in seen.items():
if status:
async with async_session() as session:
proj = await session.get(Project, pid)
if not proj or proj.status != status:
continue
else:
async with async_session() as session:
proj = await session.get(Project, pid)
if proj:
d = proj.to_dict() if hasattr(proj, "to_dict") else {"id": proj.id, "title": proj.title}
d["permission"] = perm
d["is_shared"] = True
result.append(d)
return result
+7 -4
View File
@@ -190,7 +190,7 @@ async def send_push_notification(
)
return response.status_code, sub.endpoint
except Exception as e:
logger.warning("Push send failed for sub %d: %s", sub.id, e)
logger.error("Push send failed for sub %d: %s", sub.id, e, exc_info=True)
return 0, sub.endpoint
loop = asyncio.get_event_loop()
@@ -199,9 +199,12 @@ async def send_push_notification(
for sub, result in zip(subscriptions, results):
if isinstance(result, Exception):
logger.error("Push gather exception for user %d: %s", user_id, result, exc_info=result)
continue
status_code, endpoint = result
if status_code == 410:
if status_code in (200, 201):
logger.info("Push notification sent to sub %d (status %d)", sub.id, status_code)
elif status_code == 410:
asyncio.create_task(_remove_expired_subscription(user_id, endpoint))
elif status_code and status_code not in (200, 201):
logger.warning("Push returned status %d for user %d", status_code, user_id)
elif status_code:
logger.error("Push returned unexpected status %d for user %d sub %d", status_code, user_id, sub.id)
+2
View File
@@ -26,6 +26,7 @@ async def run_research_pipeline(
user_id: int,
model: str,
buf=None,
project_id: int | None = None,
) -> Note:
"""Full research pipeline: search → fetch → synthesize → create note.
@@ -112,6 +113,7 @@ async def run_research_pipeline(
title=title,
body=body,
tags=["research"],
project_id=project_id,
)
logger.info("Research: created note id=%d title='%s'", note.id, note.title)
return note

Some files were not shown because too many files have changed in this diff Show More