Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f08d6113 | |||
| ab9b5b647c | |||
| 12f71fabdf | |||
| dd60244429 | |||
| b49efdcb11 | |||
| f6629d4bcf | |||
| 2bc054d7ef | |||
| eec241d3c0 | |||
| 8126db3203 | |||
| 807f478cac | |||
| 513019786e | |||
| 5fbee18a94 | |||
| 4f31890bde | |||
| 2ad2e943f3 | |||
| e6c89f6b88 | |||
| 96079d5b77 | |||
| c4553d937c | |||
| ee02ed37c1 | |||
| dd1fc2d506 | |||
| 5102ffb558 | |||
| 322cbc3b5e | |||
| 33f9a0a4d4 | |||
| e8d6de287b | |||
| f7742173aa | |||
| 1f6c592226 | |||
| c972af2690 | |||
| b6d01686d8 | |||
| 94d32c524a | |||
| 79040fe5db | |||
| 9293a9b198 | |||
| 4da29562bd | |||
| 4f22646c88 | |||
| 85e0501705 | |||
| b91c447b0b | |||
| 88106309f4 | |||
| d99c4e3c15 | |||
| c0b9831b0f | |||
| 700cfc664b | |||
| f125f86e16 | |||
| 95e1d47ceb | |||
| f2ab02ba2b | |||
| d11eb9145b | |||
| e631a4e615 | |||
| 9eddb8497c | |||
| 974fa6a215 | |||
| da511fcc9f | |||
| 79aec4f9c1 | |||
| 2f9c9b0e0b | |||
| 4c8044826f | |||
| 50b6902fe2 | |||
| 2a5f5fdbe1 | |||
| 1983e8f4b1 | |||
| 30826d250c | |||
| 2c36249c15 | |||
| 8fe571e175 | |||
| 6cc47c7222 | |||
| 651119cfb0 | |||
| 3a5835b109 | |||
| ff91948fa3 | |||
| 559f70eef0 | |||
| 1d82e81527 | |||
| b1674169a0 | |||
| c0b3ec7d9b | |||
| d6c8470ab2 | |||
| 9924f873b9 | |||
| 3ab16fcbdb | |||
| 8c1b19f49c | |||
| 51feaddcd3 | |||
| e3c6124912 | |||
| 964c8005d5 | |||
| 70ab3f38c6 | |||
| b255a0f90e | |||
| 9ddc418f5f | |||
| 1d4c206563 | |||
| 301352f628 | |||
| d4666bea7f |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "scribe-plugin",
|
||||||
|
"owner": { "name": "Bryan Van Deusen" },
|
||||||
|
"description": "Scribe ships its own Claude Code plugin from this repo, versioned in lockstep with the app + the /api/plugin/context contract.",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "scribe",
|
||||||
|
"source": "./plugin",
|
||||||
|
"description": "Scribe second brain: MCP tools + session-start push channel + universal process-skills."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
POSTGRES_USER=fabled
|
POSTGRES_USER=fabled
|
||||||
POSTGRES_PASSWORD=fabled
|
POSTGRES_PASSWORD=fabled
|
||||||
POSTGRES_DB=fabledassistant
|
POSTGRES_DB=scribe
|
||||||
SECRET_KEY=dev-secret-change-me
|
SECRET_KEY=dev-secret-change-me
|
||||||
|
|||||||
+87
-11
@@ -1,15 +1,15 @@
|
|||||||
# CI runs first; build only proceeds if all checks pass.
|
# CI runs first; build only proceeds if all checks pass.
|
||||||
#
|
#
|
||||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
||||||
# Push to main: typecheck + lint + test + build :<sha> (no moving tag)
|
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||||
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
||||||
#
|
#
|
||||||
# Both dev and main are gated AND built. dev pushes move the :dev tag; main
|
# Both dev and main are gated AND built. dev pushes move :dev; main pushes move
|
||||||
# pushes publish only the immutable :<sha> image — no :main tag, because
|
# :latest — main IS the production line, so :latest tracks main's tip and there
|
||||||
# :latest (release-only) is the single production pointer and a :main alias
|
# is no separate :main tag. Every push also gets an immutable :<sha> (the
|
||||||
# would just duplicate it. Running CI on the main merge commit is intentional:
|
# rollback point). A v* release tag additionally publishes the dated :<version>;
|
||||||
# main is validated and its :<sha> image is the rollback point. The v* release
|
# since main already moved :latest, the release tag's distinct job is that
|
||||||
# tag is the ONLY trigger that publishes :latest plus the immutable :<version>.
|
# :<version> marker (it refreshes :latest too, harmlessly).
|
||||||
#
|
#
|
||||||
# Successive pushes to the SAME ref supersede each other (see concurrency
|
# Successive pushes to the SAME ref supersede each other (see concurrency
|
||||||
# below), so rapid pushes don't stack identical work; dev and main runs are
|
# below), so rapid pushes don't stack identical work; dev and main runs are
|
||||||
@@ -81,6 +81,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache npm download cache
|
- name: Cache npm download cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
# Non-fatal: a transient cache-backend hiccup must NOT fail the whole
|
||||||
|
# typecheck job (it was skipping install + type check and reporting red
|
||||||
|
# on backend-only pushes — see issue task #828). On cache miss/error the
|
||||||
|
# job just installs without the cache.
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
|
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
|
||||||
@@ -137,12 +142,82 @@ jobs:
|
|||||||
uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: /opt/venv/bin/python -m pytest tests/ -q
|
# Integration tests (real Postgres) run in the `integration` job below.
|
||||||
|
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||||
|
|
||||||
|
# Real-Postgres lane (family rule 6). Exercises the async SQLAlchemy connection
|
||||||
|
# path the unit stubs can't reach — the un-awaited execution_options regression
|
||||||
|
# that made every VACUUM report 0/6 lived here. Like `test`, it runs for
|
||||||
|
# visibility and does NOT gate the build.
|
||||||
|
#
|
||||||
|
# Job key stays separator-free ("integration"): act_runner derives the service-
|
||||||
|
# container name from the (truncated) job display name and the discovery step
|
||||||
|
# filters `docker ps` by it. Service hostnames aren't routable on this runner,
|
||||||
|
# so the step resolves the Postgres container's bridge IP. No `name:` on purpose.
|
||||||
|
integration:
|
||||||
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
env:
|
||||||
|
# Config + the module engine read these at import time. DATABASE_URL itself
|
||||||
|
# is built from the discovered service IP in the run step.
|
||||||
|
SECRET_KEY: ci_integration_placeholder
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# pgvector image so `alembic upgrade head` can run migration 0067
|
||||||
|
# (CREATE EXTENSION vector). PG17 — matches the prod/quickstart image.
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: scribe
|
||||||
|
POSTGRES_PASSWORD: ci_integration
|
||||||
|
POSTGRES_DB: scribe_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U scribe"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Create virtual environment
|
||||||
|
run: uv venv /opt/venv
|
||||||
|
- name: Install package with dev deps
|
||||||
|
run: |
|
||||||
|
uv pip install --python /opt/venv/bin/python setuptools wheel
|
||||||
|
uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece
|
||||||
|
uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||||
|
- name: Integration suite (resolve service IP, migrate, test)
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
echo "=== container landscape (diagnostic for the name filter) ==="
|
||||||
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||||
|
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg17" -q | head -n1)
|
||||||
|
test -n "$PG"
|
||||||
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||||
|
test -n "$PG_IP"
|
||||||
|
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
|
||||||
|
# Wait for Postgres to accept connections (busybox sh — the runner
|
||||||
|
# default — has no bash /dev/tcp, so use Python).
|
||||||
|
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||||
|
import socket, sys, time
|
||||||
|
for _ in range(30):
|
||||||
|
try:
|
||||||
|
socket.create_connection((sys.argv[1], 5432), timeout=2).close()
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
sys.exit("postgres did not become reachable")
|
||||||
|
PY
|
||||||
|
# Real migrations build the schema; the maintenance tests then run
|
||||||
|
# VACUUM (ANALYZE) and read pg_stat_user_tables against it.
|
||||||
|
/opt/venv/bin/alembic upgrade head
|
||||||
|
/opt/venv/bin/python -m pytest tests/ -v -m integration
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build & push image
|
name: Build & push image
|
||||||
needs: [typecheck, lint, test]
|
needs: [typecheck, lint, test]
|
||||||
# Build on dev, main, and v* tag pushes. dev → :dev, main → (sha only),
|
# Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
|
||||||
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
|
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
@@ -171,8 +246,9 @@ jobs:
|
|||||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||||
;;
|
;;
|
||||||
refs/heads/main)
|
refs/heads/main)
|
||||||
# main publishes only the immutable :<sha> image (set above) —
|
# main IS the production line: publish :latest (plus the :<sha>
|
||||||
# no :main tag; :latest (release-only) is the production pointer.
|
# set above). No separate :main tag.
|
||||||
|
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||||
BUILD_VERSION="main"
|
BUILD_VERSION="main"
|
||||||
;;
|
;;
|
||||||
refs/tags/*)
|
refs/tags/*)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
632037
|
1425947
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ COPY src/ src/
|
|||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
pip install .
|
pip install .
|
||||||
|
|
||||||
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
|
COPY --from=build-frontend /build/dist/ src/scribe/static/
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY alembic/ alembic/
|
COPY alembic/ alembic/
|
||||||
|
|
||||||
@@ -30,4 +30,4 @@ ARG BUILD_VERSION=dev
|
|||||||
ENV APP_VERSION=$BUILD_VERSION
|
ENV APP_VERSION=$BUILD_VERSION
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Fabled Scribe
|
# Fabled Scribe
|
||||||
|
|
||||||
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.
|
A self-hosted work system-of-record for software projects, built to be driven by Claude Code. Notes, tasks, issues, projects, milestones, rules, and stored processes — reachable from Claude via a built-in MCP endpoint and a bundled Claude Code plugin, with a clean web UI for humans. No in-app LLM; Claude is the sole assistant.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, and an MCP server for external AI clients.
|
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system, and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
**Prerequisites:** Docker and Docker Compose. 8 GB+ RAM recommended for LLM inference.
|
**Prerequisites:** Docker and Docker Compose. No GPU or local model needed — Claude is the sole assistant, reached over MCP.
|
||||||
|
|
||||||
Download [`docker-compose.quickstart.yml`](docker-compose.quickstart.yml) from this repo, then:
|
Download [`docker-compose.quickstart.yml`](docker-compose.quickstart.yml) from this repo, then:
|
||||||
|
|
||||||
@@ -19,9 +19,7 @@ export SECRET_KEY=your-random-secret-here
|
|||||||
docker compose -f docker-compose.quickstart.yml up -d
|
docker compose -f docker-compose.quickstart.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5000`. The first user to register becomes admin. Go to **Settings → General** to pull an LLM model — `qwen3:8b` or `llama3.1:8b` are good starting points.
|
Open `http://localhost:5000`. The first user to register becomes admin. To connect Claude, create an API key under **Settings → API Keys** and install the Claude Code plugin — see [API Keys & MCP](docs/api-keys-and-mcp.md).
|
||||||
|
|
||||||
> **GPU:** Ollama runs CPU-only by default. See the comments in `docker-compose.quickstart.yml` to enable NVIDIA GPU passthrough.
|
|
||||||
|
|
||||||
> **Development:** To build from source, see [Development](docs/development.md).
|
> **Development:** To build from source, see [Development](docs/development.md).
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@ from logging.config import fileConfig
|
|||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from fabledassistant.config import Config
|
from scribe.config import Config
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""drop dead Project.auto_summary columns
|
||||||
|
|
||||||
|
Revision ID: 0063
|
||||||
|
Revises: 0062
|
||||||
|
Create Date: 2026-06-03
|
||||||
|
|
||||||
|
auto_summary + summary_updated_at were written by generate_project_summary
|
||||||
|
(Ollama), removed in the MCP pivot. Nothing has populated them since; the
|
||||||
|
field was stale-or-NULL. Drop the columns.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0063"
|
||||||
|
down_revision = "0062"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("projects", "auto_summary")
|
||||||
|
op.drop_column("projects", "summary_updated_at")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"projects",
|
||||||
|
sa.Column("summary_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"projects",
|
||||||
|
sa.Column("auto_summary", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""repo -> project bindings
|
||||||
|
|
||||||
|
Revision ID: 0064
|
||||||
|
Revises: 0063
|
||||||
|
Create Date: 2026-06-10
|
||||||
|
|
||||||
|
Maps a git repository (by its normalized remote, `repo_key`) to the Scribe
|
||||||
|
project it represents, so the SessionStart hook can resolve the active project
|
||||||
|
from the working repo instead of a project id pinned in plugin config. FKs
|
||||||
|
CASCADE so deleting a user or project removes the stale binding automatically.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0064"
|
||||||
|
down_revision = "0063"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"repo_bindings",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"project_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("repo_key", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "repo_key", name="uq_repo_bindings_user_repo"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_repo_bindings_user_id", "repo_bindings", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_repo_bindings_user_id", table_name="repo_bindings")
|
||||||
|
op.drop_table("repo_bindings")
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""issues + systems: task_kind=issue, systems, record_systems, arose_from_id
|
||||||
|
|
||||||
|
Revision ID: 0065
|
||||||
|
Revises: 0064
|
||||||
|
Create Date: 2026-06-14
|
||||||
|
|
||||||
|
Adds the corrective-work 'issue' task_kind (same-change CHECK expand per the
|
||||||
|
'new CHECK-enum values need a same-change migration' rule), a per-project
|
||||||
|
self-describing System entity, a many-to-many record<->system join (any
|
||||||
|
note/task/issue), and an issue->originating-task provenance FK.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0065"
|
||||||
|
down_revision = "0064"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. task_kind gains 'issue' (corrective work). DROP+ADD the CHECK in the
|
||||||
|
# same change that introduces the value.
|
||||||
|
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||||
|
op.create_check_constraint(
|
||||||
|
"notes_task_kind_check", "notes", "task_kind IN ('work','plan','issue')",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Provenance: an issue can point back at the task/feature it arose from.
|
||||||
|
# Distinct from parent_id (sub-task hierarchy).
|
||||||
|
op.add_column("notes", sa.Column("arose_from_id", sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_notes_arose_from_id", "notes", "notes",
|
||||||
|
["arose_from_id"], ["id"], ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index("ix_notes_arose_from_id", "notes", ["arose_from_id"])
|
||||||
|
|
||||||
|
# 3. systems: per-project, reusable, self-describing subsystem/area.
|
||||||
|
op.create_table(
|
||||||
|
"systems",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"project_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("color", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_systems_project_id", "systems", ["project_id"])
|
||||||
|
op.create_check_constraint(
|
||||||
|
"systems_status_check", "systems", "status IN ('active','archived')",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. record_systems: M2M join — any note/task/issue <-> system.
|
||||||
|
op.create_table(
|
||||||
|
"record_systems",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"note_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"system_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("systems.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("note_id", "system_id", name="uq_record_systems_note_system"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_record_systems_note_id", "record_systems", ["note_id"])
|
||||||
|
op.create_index("ix_record_systems_system_id", "record_systems", ["system_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("record_systems")
|
||||||
|
op.drop_table("systems")
|
||||||
|
op.drop_index("ix_notes_arose_from_id", table_name="notes")
|
||||||
|
op.drop_constraint("fk_notes_arose_from_id", "notes", type_="foreignkey")
|
||||||
|
op.drop_column("notes", "arose_from_id")
|
||||||
|
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||||
|
op.create_check_constraint(
|
||||||
|
"notes_task_kind_check", "notes", "task_kind IN ('work','plan')",
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""milestone-as-plan-container: milestones.body holds the plan/design
|
||||||
|
|
||||||
|
Revision ID: 0066
|
||||||
|
Revises: 0065
|
||||||
|
Create Date: 2026-06-14
|
||||||
|
|
||||||
|
T3 of plan #819. The milestone becomes the plan container: its `body` holds
|
||||||
|
the design/intent/purpose (markdown), `description` stays the one-liner, and
|
||||||
|
individual steps live as first-class child tasks (milestone_id) instead of
|
||||||
|
checkboxes crammed into a kind=plan task body. start_planning is reworked to
|
||||||
|
create a milestone instead of a kind=plan task (hard retirement going forward;
|
||||||
|
the 'plan' task_kind enum value stays valid so the historical plan-tasks are
|
||||||
|
left readable in place — no body-shredding backfill).
|
||||||
|
|
||||||
|
Schema change is just one nullable column; no data migration.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0066"
|
||||||
|
down_revision = "0065"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("milestones", sa.Column("body", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("milestones", "body")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""pgvector: note_embeddings.embedding JSONB -> vector(384) + HNSW index
|
||||||
|
|
||||||
|
Revision ID: 0067
|
||||||
|
Revises: 0066
|
||||||
|
Create Date: 2026-06-22
|
||||||
|
|
||||||
|
Moves semantic search off the full-table Python cosine scan onto a native
|
||||||
|
pgvector column so ranking + top-k run as an indexed `ORDER BY embedding <=> :q
|
||||||
|
LIMIT k` in Postgres (see services/embeddings.semantic_search_notes).
|
||||||
|
|
||||||
|
Requires a Postgres image that bundles the `vector` extension — the stack moved
|
||||||
|
from postgres:16-alpine to pgvector/pgvector:pg16 in the same change (compose +
|
||||||
|
CI). `CREATE EXTENSION IF NOT EXISTS vector` below is the in-db half.
|
||||||
|
|
||||||
|
Embeddings are DERIVED data (regenerated from note text by
|
||||||
|
backfill_note_embeddings at startup), so this migration is free to drop any row
|
||||||
|
it can't cleanly convert: only rows whose stored JSONB array is exactly 384-dim
|
||||||
|
are carried over (guarding against stale vectors from an earlier model — the
|
||||||
|
same mixed-dim hazard _cosine_similarity defended against). Dropped rows are
|
||||||
|
re-embedded on next boot.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0067"
|
||||||
|
down_revision = "0066"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
|
||||||
|
# New native-vector column, populated only from cleanly-convertible rows.
|
||||||
|
# A JSONB array like [0.1, 0.2, ...] renders to text that is exactly
|
||||||
|
# pgvector's input literal, so (embedding::text)::vector is a direct cast.
|
||||||
|
op.execute("ALTER TABLE note_embeddings ADD COLUMN embedding_vec vector(384)")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE note_embeddings
|
||||||
|
SET embedding_vec = (embedding::text)::vector
|
||||||
|
WHERE jsonb_array_length(embedding) = 384
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# Stale-dim rows (couldn't convert) are derived data — drop and let the
|
||||||
|
# startup backfill regenerate them at the current dimension.
|
||||||
|
op.execute("DELETE FROM note_embeddings WHERE embedding_vec IS NULL")
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE note_embeddings ALTER COLUMN embedding_vec SET NOT NULL")
|
||||||
|
op.execute("ALTER TABLE note_embeddings DROP COLUMN embedding")
|
||||||
|
op.execute("ALTER TABLE note_embeddings RENAME COLUMN embedding_vec TO embedding")
|
||||||
|
|
||||||
|
# HNSW index for cosine distance — matches Vector.cosine_distance (`<=>`).
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX ix_note_embeddings_embedding_hnsw
|
||||||
|
ON note_embeddings
|
||||||
|
USING hnsw (embedding vector_cosine_ops)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Back to JSONB. pgvector renders a vector to a text literal that is a valid
|
||||||
|
# JSON array, so the reverse cast is symmetric. The `vector` extension is
|
||||||
|
# intentionally left installed (other objects may depend on it; dropping an
|
||||||
|
# extension is the riskier, rarely-wanted direction).
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_note_embeddings_embedding_hnsw")
|
||||||
|
op.execute("ALTER TABLE note_embeddings ADD COLUMN embedding_json jsonb")
|
||||||
|
op.execute("UPDATE note_embeddings SET embedding_json = (embedding::text)::jsonb")
|
||||||
|
op.execute("ALTER TABLE note_embeddings ALTER COLUMN embedding_json SET NOT NULL")
|
||||||
|
op.execute("ALTER TABLE note_embeddings DROP COLUMN embedding")
|
||||||
|
op.execute("ALTER TABLE note_embeddings RENAME COLUMN embedding_json TO embedding")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""retrieval_logs: per-call semantic-retrieval telemetry for KB-injection tuning
|
||||||
|
|
||||||
|
Revision ID: 0068
|
||||||
|
Revises: 0067
|
||||||
|
Create Date: 2026-06-22
|
||||||
|
|
||||||
|
One row per semantic-retrieval call (MCP search tool, REST search route, and —
|
||||||
|
once it lands — the title-first auto-inject path). Captures the effective query
|
||||||
|
params and the score distribution of the results so the similarity threshold
|
||||||
|
and top-k can be tuned from real usage. FK-free on user_id (mirrors app_logs):
|
||||||
|
telemetry should outlive the row it describes.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0068"
|
||||||
|
down_revision = "0067"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"retrieval_logs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("source", sa.Text(), nullable=False),
|
||||||
|
sa.Column("query", sa.Text(), nullable=True),
|
||||||
|
sa.Column("threshold", sa.Float(), nullable=True),
|
||||||
|
sa.Column("limit_n", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("project_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("is_task", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("top_score", sa.Float(), nullable=True),
|
||||||
|
sa.Column("min_score", sa.Float(), nullable=True),
|
||||||
|
sa.Column("result_ids", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||||
|
sa.Column("duration_ms", sa.Float(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_retrieval_logs_created_at", "retrieval_logs", ["created_at"])
|
||||||
|
op.create_index("ix_retrieval_logs_user_id", "retrieval_logs", ["user_id"])
|
||||||
|
op.create_index("ix_retrieval_logs_source", "retrieval_logs", ["source"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_retrieval_logs_source_created_at",
|
||||||
|
"retrieval_logs",
|
||||||
|
["source", sa.text("created_at DESC")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_retrieval_logs_source_created_at", table_name="retrieval_logs")
|
||||||
|
op.drop_index("ix_retrieval_logs_source", table_name="retrieval_logs")
|
||||||
|
op.drop_index("ix_retrieval_logs_user_id", table_name="retrieval_logs")
|
||||||
|
op.drop_index("ix_retrieval_logs_created_at", table_name="retrieval_logs")
|
||||||
|
op.drop_table("retrieval_logs")
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""drop events table + notes.metadata column (retire calendar + entity surfaces)
|
||||||
|
|
||||||
|
Revision ID: 0069
|
||||||
|
Revises: 0068
|
||||||
|
Create Date: 2026-07-19
|
||||||
|
|
||||||
|
The personal-assistant surfaces (calendar/events + CalDAV, and the typed
|
||||||
|
person/place/list entities that stored structured fields in notes.metadata)
|
||||||
|
were removed when Scribe narrowed to a Claude-Code work system-of-record.
|
||||||
|
This migration drops their storage:
|
||||||
|
|
||||||
|
- the `events` table (all calendar/CalDAV data)
|
||||||
|
- the `notes.metadata` (entity_meta) JSONB column — it only ever held
|
||||||
|
person/place/list structured fields. The `note_type` column STAYS: it
|
||||||
|
also distinguishes 'process' notes.
|
||||||
|
- orphan CalDAV settings rows (nothing reads them after the removal)
|
||||||
|
|
||||||
|
Downgrade recreates the table + column structure at its pre-removal shape.
|
||||||
|
The dropped data itself is not recoverable.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0069"
|
||||||
|
down_revision = "0068"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Entity metadata column (person/place/list structured fields). The
|
||||||
|
# note_type column is intentionally kept — it also marks 'process' notes.
|
||||||
|
op.drop_column("notes", "metadata")
|
||||||
|
|
||||||
|
# Calendar / CalDAV storage. Dropping the table drops its indexes + the
|
||||||
|
# duration CHECK constraint with it.
|
||||||
|
op.drop_table("events")
|
||||||
|
|
||||||
|
# Orphan CalDAV integration settings — no code reads them post-removal.
|
||||||
|
op.execute("DELETE FROM settings WHERE key LIKE 'caldav%'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Recreate the events table at its pre-removal schema (empty — the data is
|
||||||
|
# gone). Mirrors the model as of 0037 (reminders) + 0043 (duration_minutes)
|
||||||
|
# + 0057 (soft-delete columns/index).
|
||||||
|
op.create_table(
|
||||||
|
"events",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("project_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("uid", sa.Text(), nullable=False),
|
||||||
|
sa.Column("title", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("start_dt", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("duration_minutes", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("all_day", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("location", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("caldav_uid", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("color", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("recurrence", sa.Text(), nullable=True),
|
||||||
|
sa.Column("reminder_minutes", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||||
|
nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True),
|
||||||
|
nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"duration_minutes IS NULL OR duration_minutes >= 0",
|
||||||
|
name="events_duration_minutes_non_negative",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_events_deleted_at", "events", ["deleted_at"])
|
||||||
|
|
||||||
|
# Re-add the entity metadata column.
|
||||||
|
op.add_column("notes", sa.Column("metadata", JSONB(), nullable=True))
|
||||||
+12
-8
@@ -2,13 +2,13 @@ services:
|
|||||||
app:
|
app:
|
||||||
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
|
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant"
|
DATABASE_URL: "postgresql+asyncpg://scribe:${DB_PASSWORD}@db:5432/scribe"
|
||||||
SECRET_KEY: "${SECRET_KEY}"
|
SECRET_KEY: "${SECRET_KEY}"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||||
TRUST_PROXY_HEADERS: "true"
|
TRUST_PROXY_HEADERS: "true"
|
||||||
SECURE_COOKIES: "true"
|
SECURE_COOKIES: "true"
|
||||||
networks:
|
networks:
|
||||||
- fabledassistant_backend
|
- scribe_backend
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -21,21 +21,25 @@ services:
|
|||||||
max_attempts: 5
|
max_attempts: 5
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
# pgvector image (Debian/glibc, PG17) — bundles the `vector` extension that
|
||||||
|
# migration 0067 enables. Moved off postgres:16-alpine via logical
|
||||||
|
# dump/restore (which doubles as the PG16->PG17 major upgrade); see the
|
||||||
|
# TRANSITION runbook in the PR.
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
stop_grace_period: 120s
|
stop_grace_period: 120s
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: fabled
|
POSTGRES_USER: scribe
|
||||||
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||||
POSTGRES_DB: fabledassistant
|
POSTGRES_DB: scribe
|
||||||
networks:
|
networks:
|
||||||
- fabledassistant_backend
|
- scribe_backend
|
||||||
# Lenient by design: a transient host exec/healthcheck stall (incident:
|
# Lenient by design: a transient host exec/healthcheck stall (incident:
|
||||||
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
|
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
|
||||||
# escalate to killing the DB. Health here only gates app startup order.
|
# escalate to killing the DB. Health here only gates app startup order.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U fabled"]
|
test: ["CMD-SHELL", "pg_isready -U scribe"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -51,5 +55,5 @@ volumes:
|
|||||||
pgdata:
|
pgdata:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
fabledassistant_backend:
|
scribe_backend:
|
||||||
driver: overlay
|
driver: overlay
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant"
|
DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe"
|
||||||
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
|
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -35,18 +35,19 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
# pgvector image (PG17) — bundles the `vector` extension (migration 0067).
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
stop_grace_period: 120s
|
stop_grace_period: 120s
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: fabled
|
POSTGRES_USER: scribe
|
||||||
POSTGRES_PASSWORD: fabled
|
POSTGRES_PASSWORD: scribe
|
||||||
POSTGRES_DB: fabledassistant
|
POSTGRES_DB: scribe
|
||||||
# Lenient by design: a transient host exec/healthcheck stall must never
|
# Lenient by design: a transient host exec/healthcheck stall must never
|
||||||
# escalate to killing the DB. Health here only gates app startup order.
|
# escalate to killing the DB. Health here only gates app startup order.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U fabled"]
|
test: ["CMD-SHELL", "pg_isready -U scribe"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
+5
-5
@@ -11,7 +11,7 @@ services:
|
|||||||
# To use a bind mount instead (gives direct host access to all app data):
|
# To use a bind mount instead (gives direct host access to all app data):
|
||||||
# - ./data:/data
|
# - ./data:/data
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
|
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}"
|
||||||
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
||||||
# Uncomment if you have a SearXNG instance you want to surface in the
|
# Uncomment if you have a SearXNG instance you want to surface in the
|
||||||
# Integrations tab as a configured web-search backend:
|
# Integrations tab as a configured web-search backend:
|
||||||
@@ -30,13 +30,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-fabled}
|
POSTGRES_USER: ${POSTGRES_USER:-scribe}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-fabledassistant}
|
POSTGRES_DB: ${POSTGRES_DB:-scribe}
|
||||||
# Lenient by design: a transient host exec/healthcheck stall must never
|
# Lenient by design: a transient host exec/healthcheck stall must never
|
||||||
# escalate to killing the DB. Health here only gates app startup order.
|
# escalate to killing the DB. Health here only gates app startup order.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fabled}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A Python MCP (Model Context Protocol) server that lets Claude directly interface
|
|||||||
|
|
||||||
The work is split into two sub-projects:
|
The work is split into two sub-projects:
|
||||||
|
|
||||||
1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication
|
1. **Fable API Key Feature** — additions to the main `scribe` project to support bearer token authentication
|
||||||
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
|
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
|
||||||
|
|
||||||
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
|
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
|
||||||
@@ -134,7 +134,7 @@ The MCP tool reads tokens until it receives `type: "done"`, then returns `respon
|
|||||||
|
|
||||||
### Location
|
### Location
|
||||||
|
|
||||||
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable.
|
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `scribe`. It will be extracted to its own Forgejo repo once stable.
|
||||||
|
|
||||||
### Package Structure
|
### Package Structure
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ Claude Code spawns the process over stdio automatically. No Docker, no daemon.
|
|||||||
|
|
||||||
## Build & Repo Plan
|
## Build & Repo Plan
|
||||||
|
|
||||||
1. Implement and test within `fabledassistant/fable-mcp/`
|
1. Implement and test within `scribe/fable-mcp/`
|
||||||
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
|
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
|
||||||
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
|
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done.
|
**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done.
|
||||||
|
|
||||||
**Deployment decision:** `fable-mcp/` stays permanently inside the `fabledassistant` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance.
|
**Deployment decision:** `fable-mcp/` stays permanently inside the `scribe` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance.
|
||||||
|
|
||||||
**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both.
|
**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both.
|
||||||
|
|
||||||
@@ -23,16 +23,16 @@
|
|||||||
| Action | File | Purpose |
|
| Action | File | Purpose |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table |
|
| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table |
|
||||||
| Create | `src/fabledassistant/models/api_key.py` | `ApiKey` SQLAlchemy model |
|
| Create | `src/scribe/models/api_key.py` | `ApiKey` SQLAlchemy model |
|
||||||
| Modify | `src/fabledassistant/models/__init__.py` | Export `ApiKey` |
|
| Modify | `src/scribe/models/__init__.py` | Export `ApiKey` |
|
||||||
| Create | `src/fabledassistant/services/api_keys.py` | create/list/revoke/lookup service functions |
|
| Create | `src/scribe/services/api_keys.py` | create/list/revoke/lookup service functions |
|
||||||
| Modify | `src/fabledassistant/auth.py` | Add bearer token check before session fallback |
|
| Modify | `src/scribe/auth.py` | Add bearer token check before session fallback |
|
||||||
| Create | `src/fabledassistant/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint |
|
| Create | `src/scribe/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint |
|
||||||
| Modify | `src/fabledassistant/app.py` | Register `api_keys_bp` and `search_bp` |
|
| Modify | `src/scribe/app.py` | Register `api_keys_bp` and `search_bp` |
|
||||||
| Modify | `src/fabledassistant/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` |
|
| Modify | `src/scribe/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` |
|
||||||
| Modify | `src/fabledassistant/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` |
|
| Modify | `src/scribe/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` |
|
||||||
| Modify | `src/fabledassistant/routes/chat.py:73-79` | Pass `conversation_type` from POST body |
|
| Modify | `src/scribe/routes/chat.py:73-79` | Pass `conversation_type` from POST body |
|
||||||
| Create | `src/fabledassistant/routes/search.py` | `GET /api/search` semantic search endpoint |
|
| Create | `src/scribe/routes/search.py` | `GET /api/search` semantic search endpoint |
|
||||||
| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab |
|
| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab |
|
||||||
| Create | `tests/test_api_keys.py` | Unit tests for service + auth |
|
| Create | `tests/test_api_keys.py` | Unit tests for service + auth |
|
||||||
| Create | `tests/test_search_route.py` | Unit test for search endpoint |
|
| Create | `tests/test_search_route.py` | Unit test for search endpoint |
|
||||||
@@ -42,13 +42,13 @@
|
|||||||
### Task 1: ApiKey model + migration
|
### Task 1: ApiKey model + migration
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/fabledassistant/models/api_key.py`
|
- Create: `src/scribe/models/api_key.py`
|
||||||
- Create: `alembic/versions/0027_add_api_keys.py`
|
- Create: `alembic/versions/0027_add_api_keys.py`
|
||||||
- Modify: `src/fabledassistant/models/__init__.py`
|
- Modify: `src/scribe/models/__init__.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the model**
|
- [ ] **Step 1: Write the model**
|
||||||
|
|
||||||
Create `src/fabledassistant/models/api_key.py`:
|
Create `src/scribe/models/api_key.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -56,8 +56,8 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import CreatedAtMixin
|
from scribe.models.base import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class ApiKey(Base, CreatedAtMixin):
|
class ApiKey(Base, CreatedAtMixin):
|
||||||
@@ -97,10 +97,10 @@ class ApiKey(Base, CreatedAtMixin):
|
|||||||
|
|
||||||
- [ ] **Step 2: Export from models __init__**
|
- [ ] **Step 2: Export from models __init__**
|
||||||
|
|
||||||
In `src/fabledassistant/models/__init__.py`, add after the last import line:
|
In `src/scribe/models/__init__.py`, add after the last import line:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
|
from scribe.models.api_key import ApiKey # noqa: E402, F401
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Write the migration**
|
- [ ] **Step 3: Write the migration**
|
||||||
@@ -155,8 +155,8 @@ Expected: `Running upgrade 0026 -> 0027, add api_keys table`
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/models/api_key.py \
|
git add src/scribe/models/api_key.py \
|
||||||
src/fabledassistant/models/__init__.py \
|
src/scribe/models/__init__.py \
|
||||||
alembic/versions/0027_add_api_keys.py
|
alembic/versions/0027_add_api_keys.py
|
||||||
git commit -m "feat: add ApiKey model and migration 0027"
|
git commit -m "feat: add ApiKey model and migration 0027"
|
||||||
```
|
```
|
||||||
@@ -166,7 +166,7 @@ git commit -m "feat: add ApiKey model and migration 0027"
|
|||||||
### Task 2: ApiKey service
|
### Task 2: ApiKey service
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/fabledassistant/services/api_keys.py`
|
- Create: `src/scribe/services/api_keys.py`
|
||||||
- Create: `tests/test_api_keys.py` (service tests)
|
- Create: `tests/test_api_keys.py` (service tests)
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
- [ ] **Step 1: Write the failing tests**
|
||||||
@@ -181,7 +181,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from fabledassistant.services.api_keys import (
|
from scribe.services.api_keys import (
|
||||||
_hash_key,
|
_hash_key,
|
||||||
generate_key,
|
generate_key,
|
||||||
create_api_key,
|
create_api_key,
|
||||||
@@ -212,7 +212,7 @@ def test_hash_key_is_sha256():
|
|||||||
def test_generate_key_prefix():
|
def test_generate_key_prefix():
|
||||||
key = "fmcp_abcdefghijklmnop"
|
key = "fmcp_abcdefghijklmnop"
|
||||||
# prefix is first 12 chars of the full key
|
# prefix is first 12 chars of the full key
|
||||||
from fabledassistant.services.api_keys import _key_prefix
|
from scribe.services.api_keys import _key_prefix
|
||||||
assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars
|
assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars
|
||||||
|
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ async def test_create_api_key_returns_full_key():
|
|||||||
mock_key_obj.id = 1
|
mock_key_obj.id = 1
|
||||||
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
|
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
|
||||||
|
|
||||||
with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx:
|
with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
|
||||||
mock_session = AsyncMock()
|
mock_session = AsyncMock()
|
||||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||||
@@ -238,7 +238,7 @@ async def test_create_api_key_returns_full_key():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_lookup_key_returns_none_for_unknown():
|
async def test_lookup_key_returns_none_for_unknown():
|
||||||
with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx:
|
with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
|
||||||
mock_session = AsyncMock()
|
mock_session = AsyncMock()
|
||||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||||
@@ -262,7 +262,7 @@ Expected: `ImportError` or `ModuleNotFoundError` (service doesn't exist yet)
|
|||||||
|
|
||||||
- [ ] **Step 3: Write the service**
|
- [ ] **Step 3: Write the service**
|
||||||
|
|
||||||
Create `src/fabledassistant/services/api_keys.py`:
|
Create `src/scribe/services/api_keys.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -271,8 +271,8 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from fabledassistant.models import async_session
|
from scribe.models import async_session
|
||||||
from fabledassistant.models.api_key import ApiKey
|
from scribe.models.api_key import ApiKey
|
||||||
|
|
||||||
|
|
||||||
def generate_key() -> str:
|
def generate_key() -> str:
|
||||||
@@ -365,7 +365,7 @@ Expected: all tests pass
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/services/api_keys.py tests/test_api_keys.py
|
git add src/scribe/services/api_keys.py tests/test_api_keys.py
|
||||||
git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
|
git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
|
|||||||
### Task 3: Auth middleware — bearer token support
|
### Task 3: Auth middleware — bearer token support
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `src/fabledassistant/auth.py`
|
- Modify: `src/scribe/auth.py`
|
||||||
- Modify: `tests/test_api_keys.py` (add auth middleware tests)
|
- Modify: `tests/test_api_keys.py` (add auth middleware tests)
|
||||||
|
|
||||||
- [ ] **Step 1: Add auth middleware tests**
|
- [ ] **Step 1: Add auth middleware tests**
|
||||||
@@ -400,7 +400,7 @@ def test_scope_validation():
|
|||||||
async def test_bearer_token_path_sets_g_user(monkeypatch):
|
async def test_bearer_token_path_sets_g_user(monkeypatch):
|
||||||
"""Valid bearer token authenticates and sets g.user and g.api_key."""
|
"""Valid bearer token authenticates and sets g.user and g.api_key."""
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from fabledassistant.auth import _check_auth
|
from scribe.auth import _check_auth
|
||||||
|
|
||||||
# Mock ApiKey object
|
# Mock ApiKey object
|
||||||
fake_key = MagicMock()
|
fake_key = MagicMock()
|
||||||
@@ -411,8 +411,8 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
|
|||||||
fake_user = MagicMock()
|
fake_user = MagicMock()
|
||||||
fake_user.role = "user"
|
fake_user.role = "user"
|
||||||
|
|
||||||
monkeypatch.setattr("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key))
|
monkeypatch.setattr("scribe.auth.lookup_key", AsyncMock(return_value=fake_key))
|
||||||
monkeypatch.setattr("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user))
|
monkeypatch.setattr("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user))
|
||||||
|
|
||||||
called_with_user = {}
|
called_with_user = {}
|
||||||
|
|
||||||
@@ -434,7 +434,7 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
|
|||||||
async with app.test_request_context("/test", method="GET",
|
async with app.test_request_context("/test", method="GET",
|
||||||
headers={"Authorization": "Bearer fmcp_valid"}):
|
headers={"Authorization": "Bearer fmcp_valid"}):
|
||||||
# Just verify _check_auth calls lookup_key with the right token
|
# Just verify _check_auth calls lookup_key with the right token
|
||||||
import fabledassistant.auth as auth_module
|
import scribe.auth as auth_module
|
||||||
auth_module.lookup_key.assert_called_with # callable
|
auth_module.lookup_key.assert_called_with # callable
|
||||||
|
|
||||||
|
|
||||||
@@ -442,7 +442,7 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
|
|||||||
async def test_read_only_key_blocked_on_post():
|
async def test_read_only_key_blocked_on_post():
|
||||||
"""Read-only API key returns 403 on non-GET requests."""
|
"""Read-only API key returns 403 on non-GET requests."""
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from fabledassistant.auth import _check_auth
|
from scribe.auth import _check_auth
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
|
|
||||||
fake_key = MagicMock()
|
fake_key = MagicMock()
|
||||||
@@ -459,8 +459,8 @@ async def test_read_only_key_blocked_on_post():
|
|||||||
headers={"Authorization": "Bearer fmcp_readonly"}),
|
headers={"Authorization": "Bearer fmcp_readonly"}),
|
||||||
):
|
):
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
with patch("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \
|
with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \
|
||||||
patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
|
patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
|
||||||
|
|
||||||
async def dummy():
|
async def dummy():
|
||||||
return "ok"
|
return "ok"
|
||||||
@@ -481,15 +481,15 @@ docker compose run --rm app pytest tests/test_api_keys.py -v
|
|||||||
|
|
||||||
- [ ] **Step 3: Update auth.py**
|
- [ ] **Step 3: Update auth.py**
|
||||||
|
|
||||||
Replace `src/fabledassistant/auth.py` with:
|
Replace `src/scribe/auth.py` with:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import functools
|
import functools
|
||||||
|
|
||||||
from quart import g, jsonify, request, session
|
from quart import g, jsonify, request, session
|
||||||
|
|
||||||
from fabledassistant.services.auth import get_user_by_id
|
from scribe.services.auth import get_user_by_id
|
||||||
from fabledassistant.services.api_keys import lookup_key
|
from scribe.services.api_keys import lookup_key
|
||||||
|
|
||||||
|
|
||||||
def _check_auth(f, required_role: str | None = None):
|
def _check_auth(f, required_role: str | None = None):
|
||||||
@@ -556,7 +556,7 @@ Expected: all existing tests still pass
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/auth.py tests/test_api_keys.py
|
git add src/scribe/auth.py tests/test_api_keys.py
|
||||||
git commit -m "feat: add bearer token auth to _check_auth, falls back to session"
|
git commit -m "feat: add bearer token auth to _check_auth, falls back to session"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -565,18 +565,18 @@ git commit -m "feat: add bearer token auth to _check_auth, falls back to session
|
|||||||
### Task 4: API key routes + app registration
|
### Task 4: API key routes + app registration
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/fabledassistant/routes/api_keys.py`
|
- Create: `src/scribe/routes/api_keys.py`
|
||||||
- Modify: `src/fabledassistant/app.py`
|
- Modify: `src/scribe/app.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the routes**
|
- [ ] **Step 1: Write the routes**
|
||||||
|
|
||||||
Create `src/fabledassistant/routes/api_keys.py`:
|
Create `src/scribe/routes/api_keys.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from scribe.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key
|
from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key
|
||||||
|
|
||||||
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
|
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
|
||||||
|
|
||||||
@@ -619,10 +619,10 @@ async def revoke_key_route(key_id: int):
|
|||||||
|
|
||||||
- [ ] **Step 2: Register in app.py**
|
- [ ] **Step 2: Register in app.py**
|
||||||
|
|
||||||
In `src/fabledassistant/app.py`, add the import alongside the other route imports:
|
In `src/scribe/app.py`, add the import alongside the other route imports:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fabledassistant.routes.api_keys import api_keys_bp
|
from scribe.routes.api_keys import api_keys_bp
|
||||||
```
|
```
|
||||||
|
|
||||||
And add the registration line after `app.register_blueprint(users_bp)`:
|
And add the registration line after `app.register_blueprint(users_bp)`:
|
||||||
@@ -651,7 +651,7 @@ curl -s http://localhost:8080/api/auth/me \
|
|||||||
- [ ] **Step 4: Commit**
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/routes/api_keys.py src/fabledassistant/app.py
|
git add src/scribe/routes/api_keys.py src/scribe/app.py
|
||||||
git commit -m "feat: add API key CRUD routes and register blueprint"
|
git commit -m "feat: add API key CRUD routes and register blueprint"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -660,14 +660,14 @@ git commit -m "feat: add API key CRUD routes and register blueprint"
|
|||||||
### Task 5: Conversation type wiring
|
### Task 5: Conversation type wiring
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `src/fabledassistant/services/chat.py` (lines 17-30 and 123-135)
|
- Modify: `src/scribe/services/chat.py` (lines 17-30 and 123-135)
|
||||||
- Modify: `src/fabledassistant/routes/chat.py` (lines 73-79)
|
- Modify: `src/scribe/routes/chat.py` (lines 73-79)
|
||||||
|
|
||||||
Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion.
|
Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion.
|
||||||
|
|
||||||
- [ ] **Step 1: Update `create_conversation` service**
|
- [ ] **Step 1: Update `create_conversation` service**
|
||||||
|
|
||||||
In `src/fabledassistant/services/chat.py`, change the function signature at line 17:
|
In `src/scribe/services/chat.py`, change the function signature at line 17:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async def create_conversation(
|
async def create_conversation(
|
||||||
@@ -692,7 +692,7 @@ async def create_conversation(
|
|||||||
|
|
||||||
- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"**
|
- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"**
|
||||||
|
|
||||||
In `src/fabledassistant/services/chat.py`, update the WHERE clause at line 130:
|
In `src/scribe/services/chat.py`, update the WHERE clause at line 130:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -708,7 +708,7 @@ result = await session.execute(
|
|||||||
|
|
||||||
- [ ] **Step 3: Update the POST route to accept conversation_type**
|
- [ ] **Step 3: Update the POST route to accept conversation_type**
|
||||||
|
|
||||||
In `src/fabledassistant/routes/chat.py`, update `create_conversation_route` (around line 73):
|
In `src/scribe/routes/chat.py`, update `create_conversation_route` (around line 73):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@chat_bp.route("/conversations", methods=["POST"])
|
@chat_bp.route("/conversations", methods=["POST"])
|
||||||
@@ -737,7 +737,7 @@ Expected: all tests pass
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/services/chat.py src/fabledassistant/routes/chat.py
|
git add src/scribe/services/chat.py src/scribe/routes/chat.py
|
||||||
git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep"
|
git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -746,8 +746,8 @@ git commit -m "feat: wire conversation_type through create_conversation, exclude
|
|||||||
### Task 6: Semantic search endpoint
|
### Task 6: Semantic search endpoint
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/fabledassistant/routes/search.py`
|
- Create: `src/scribe/routes/search.py`
|
||||||
- Modify: `src/fabledassistant/app.py`
|
- Modify: `src/scribe/app.py`
|
||||||
- Create: `tests/test_search_route.py`
|
- Create: `tests/test_search_route.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
- [ ] **Step 1: Write the failing test**
|
||||||
@@ -762,7 +762,7 @@ from unittest.mock import patch, AsyncMock
|
|||||||
|
|
||||||
def test_content_type_mapping():
|
def test_content_type_mapping():
|
||||||
"""Verify content_type string maps to correct is_task value."""
|
"""Verify content_type string maps to correct is_task value."""
|
||||||
from fabledassistant.routes.search import _content_type_to_is_task
|
from scribe.routes.search import _content_type_to_is_task
|
||||||
assert _content_type_to_is_task("note") is False
|
assert _content_type_to_is_task("note") is False
|
||||||
assert _content_type_to_is_task("task") is True
|
assert _content_type_to_is_task("task") is True
|
||||||
assert _content_type_to_is_task("all") is None
|
assert _content_type_to_is_task("all") is None
|
||||||
@@ -779,13 +779,13 @@ Expected: `ImportError` (module doesn't exist yet)
|
|||||||
|
|
||||||
- [ ] **Step 3: Write the route**
|
- [ ] **Step 3: Write the route**
|
||||||
|
|
||||||
Create `src/fabledassistant/routes/search.py`:
|
Create `src/scribe/routes/search.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from scribe.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.services.embeddings import semantic_search_notes
|
from scribe.services.embeddings import semantic_search_notes
|
||||||
|
|
||||||
search_bp = Blueprint("search", __name__, url_prefix="/api/search")
|
search_bp = Blueprint("search", __name__, url_prefix="/api/search")
|
||||||
|
|
||||||
@@ -834,9 +834,9 @@ Note: check `services/embeddings.py` to confirm the return type of `semantic_sea
|
|||||||
|
|
||||||
- [ ] **Step 4: Register in app.py**
|
- [ ] **Step 4: Register in app.py**
|
||||||
|
|
||||||
Add to imports in `src/fabledassistant/app.py`:
|
Add to imports in `src/scribe/app.py`:
|
||||||
```python
|
```python
|
||||||
from fabledassistant.routes.search import search_bp
|
from scribe.routes.search import search_bp
|
||||||
```
|
```
|
||||||
|
|
||||||
Add registration:
|
Add registration:
|
||||||
@@ -855,8 +855,8 @@ Expected: all tests pass
|
|||||||
- [ ] **Step 6: Commit**
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/routes/search.py \
|
git add src/scribe/routes/search.py \
|
||||||
src/fabledassistant/app.py \
|
src/scribe/app.py \
|
||||||
tests/test_search_route.py
|
tests/test_search_route.py
|
||||||
git commit -m "feat: add GET /api/search semantic search endpoint"
|
git commit -m "feat: add GET /api/search semantic search endpoint"
|
||||||
```
|
```
|
||||||
@@ -1905,7 +1905,7 @@ async def send_message(
|
|||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: verify the Fable message POST endpoint path by checking `src/fabledassistant/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs.
|
Note: verify the Fable message POST endpoint path by checking `src/scribe/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs.
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests**
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
|||||||
@@ -39,20 +39,20 @@
|
|||||||
|
|
||||||
## New Backend Files
|
## New Backend Files
|
||||||
|
|
||||||
### `src/fabledassistant/services/stt.py`
|
### `src/scribe/services/stt.py`
|
||||||
Lazy singleton `WhisperModel` loader. Public API:
|
Lazy singleton `WhisperModel` loader. Public API:
|
||||||
- `load_stt_model()` — called at startup via `asyncio.create_task`
|
- `load_stt_model()` — called at startup via `asyncio.create_task`
|
||||||
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
|
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
|
||||||
- `stt_available() -> bool`
|
- `stt_available() -> bool`
|
||||||
|
|
||||||
### `src/fabledassistant/services/tts.py`
|
### `src/scribe/services/tts.py`
|
||||||
Lazy singleton `KPipeline` loader. Public API:
|
Lazy singleton `KPipeline` loader. Public API:
|
||||||
- `load_tts_model()` — called at startup
|
- `load_tts_model()` — called at startup
|
||||||
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
|
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
|
||||||
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
|
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
|
||||||
- `tts_available() -> bool`
|
- `tts_available() -> bool`
|
||||||
|
|
||||||
### `src/fabledassistant/routes/voice.py`
|
### `src/scribe/routes/voice.py`
|
||||||
Blueprint at `/api/voice`, all routes `@login_required`.
|
Blueprint at `/api/voice`, all routes `@login_required`.
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
@@ -66,27 +66,27 @@ Blueprint at `/api/voice`, all routes `@login_required`.
|
|||||||
|
|
||||||
## Modified Backend Files
|
## Modified Backend Files
|
||||||
|
|
||||||
### `src/fabledassistant/app.py`
|
### `src/scribe/app.py`
|
||||||
- Register `voice_bp` blueprint
|
- Register `voice_bp` blueprint
|
||||||
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
|
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
|
||||||
|
|
||||||
### `src/fabledassistant/config.py`
|
### `src/scribe/config.py`
|
||||||
- Add 4 new env var attributes
|
- Add 4 new env var attributes
|
||||||
- Add validation in `validate()`
|
- Add validation in `validate()`
|
||||||
|
|
||||||
### `src/fabledassistant/services/llm.py`
|
### `src/scribe/services/llm.py`
|
||||||
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
|
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
|
||||||
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
|
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
|
||||||
- Append style modifier based on `voice_speech_style`
|
- Append style modifier based on `voice_speech_style`
|
||||||
|
|
||||||
### `src/fabledassistant/services/generation_task.py`
|
### `src/scribe/services/generation_task.py`
|
||||||
- Add `voice_mode: bool = False` to `run_generation()`
|
- Add `voice_mode: bool = False` to `run_generation()`
|
||||||
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
|
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
|
||||||
|
|
||||||
### `src/fabledassistant/routes/chat.py`
|
### `src/scribe/routes/chat.py`
|
||||||
- Allow `"voice"` in `conversation_type` whitelist
|
- Allow `"voice"` in `conversation_type` whitelist
|
||||||
|
|
||||||
### `src/fabledassistant/services/chat.py`
|
### `src/scribe/services/chat.py`
|
||||||
- Exclude `conversation_type == "voice"` from auto-cleanup retention
|
- Exclude `conversation_type == "voice"` from auto-cleanup retention
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+48
-94
@@ -1,4 +1,4 @@
|
|||||||
# API Keys and Fable MCP
|
# API Keys and Scribe MCP
|
||||||
|
|
||||||
## API Keys
|
## API Keys
|
||||||
|
|
||||||
@@ -19,11 +19,10 @@ Admin-level operations (log access, user management) require a `write`-scoped ke
|
|||||||
2. Enter a name (e.g. "Claude MCP", "Home Server")
|
2. Enter a name (e.g. "Claude MCP", "Home Server")
|
||||||
3. Choose scope
|
3. Choose scope
|
||||||
4. Click **Generate Key**
|
4. Click **Generate Key**
|
||||||
5. Copy the key immediately — it is shown only once
|
5. Copy the key immediately — it is shown only once (the token is `fmcp_`-prefixed)
|
||||||
|
|
||||||
After creation you can download:
|
Paste the key into the `Authorization: Bearer <key>` header of your MCP client
|
||||||
- **`.env` file** — `FABLE_URL` + `FABLE_API_KEY` ready to paste
|
config (see **Scribe MCP Server** below).
|
||||||
- **Claude config JSON** — `mcpServers` block ready to merge into `~/.claude.json`
|
|
||||||
|
|
||||||
### Revoking a Key
|
### Revoking a Key
|
||||||
|
|
||||||
@@ -31,73 +30,35 @@ Click **Revoke** next to the key in the API Keys table and confirm. Revoked keys
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fable MCP Server
|
## Scribe MCP Server
|
||||||
|
|
||||||
The Fable MCP server (`fable-mcp`) exposes Fable as a set of MCP tools that Claude (and other MCP clients) can use to read and write your notes, tasks, projects, and more.
|
Scribe exposes itself as a set of MCP tools that Claude (and other MCP clients)
|
||||||
|
can use to read and write your notes, tasks, projects, rulebooks, and more. The
|
||||||
|
server is **built into the app** — it is mounted as a streamable-HTTP endpoint
|
||||||
|
at **`/mcp`** on the running Scribe instance (`src/scribe/mcp/server.py`). There
|
||||||
|
is nothing to install: no wheel, no separate package, no CLI. You connect a
|
||||||
|
client straight to the URL with a Bearer token.
|
||||||
|
|
||||||
### Download
|
### Authentication
|
||||||
|
|
||||||
The wheel is bundled into the Docker image at build time and available for download from **Settings → API Keys → Fable MCP** when you are logged in.
|
Authenticate with an API key generated from **Settings → API Keys** (see above),
|
||||||
|
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
|
||||||
You can also download it directly:
|
read tools (`get_*`, `list_*`, `search`, `enter_project`); any write/delete tool
|
||||||
```
|
is rejected with `403`. A `write`-scoped key may call everything.
|
||||||
GET /api/fable-mcp/download
|
|
||||||
```
|
|
||||||
(Requires login — authenticated browser session or API key in `Authorization: Bearer <key>` header.)
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install the wheel
|
|
||||||
pip install fable_mcp-*.whl
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
fable-mcp --help
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
The server reads two environment variables:
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `FABLE_URL` | Base URL of your Fable instance (e.g. `https://notes.example.com`) |
|
|
||||||
| `FABLE_API_KEY` | API key generated from Settings → API Keys |
|
|
||||||
|
|
||||||
Create a `.env` file in your working directory, or set them in your shell / MCP config.
|
|
||||||
|
|
||||||
### Claude Code (Global)
|
|
||||||
|
|
||||||
Add to `~/.claude.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"fable": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "fable-mcp",
|
|
||||||
"env": {
|
|
||||||
"FABLE_URL": "https://your-fable-instance.example.com",
|
|
||||||
"FABLE_API_KEY": "your-api-key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Claude Code (Project-scoped)
|
### Claude Code (Project-scoped)
|
||||||
|
|
||||||
Add a `.mcp.json` at the project root (same format as the global config). Project-scoped config takes precedence over global when the same server name is defined in both. This is useful for using a dev instance or admin key within a specific project.
|
Add a `.mcp.json` at the project root. The server `type` is `http` and the URL is
|
||||||
|
your instance's `/mcp` endpoint:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"fable": {
|
"scribe": {
|
||||||
"type": "stdio",
|
"type": "http",
|
||||||
"command": "fable-mcp",
|
"url": "https://your-scribe-instance.example.com/mcp",
|
||||||
"env": {
|
"headers": {
|
||||||
"FABLE_URL": "http://localhost:5000",
|
"Authorization": "Bearer fmcp_your-api-key"
|
||||||
"FABLE_API_KEY": "your-dev-api-key"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,39 +67,32 @@ Add a `.mcp.json` at the project root (same format as the global config). Projec
|
|||||||
|
|
||||||
Note: `.mcp.json` contains an API key and should be added to `.gitignore`.
|
Note: `.mcp.json` contains an API key and should be added to `.gitignore`.
|
||||||
|
|
||||||
|
### Claude Code (Global)
|
||||||
|
|
||||||
|
The same `mcpServers` block can live in `~/.claude.json` to make the server
|
||||||
|
available across all projects. A project-scoped `.mcp.json` takes precedence over
|
||||||
|
the global entry when both define the same server name — useful for pointing a
|
||||||
|
specific project at a dev instance or an admin key.
|
||||||
|
|
||||||
### Available Tools
|
### Available Tools
|
||||||
|
|
||||||
| Tool | Description |
|
The tool surface is large (~70 tools) and evolves with the app, so the live
|
||||||
|------|-------------|
|
registration in **`src/scribe/mcp/tools/`** is the source of truth rather than a
|
||||||
| `fable_list_notes` | List notes, filter by tag or search text |
|
table here. The tools are grouped by family:
|
||||||
| `fable_get_note` | Fetch a note by ID |
|
|
||||||
| `fable_create_note` | Create a new note |
|
|
||||||
| `fable_update_note` | Update a note |
|
|
||||||
| `fable_delete_note` | Delete a note |
|
|
||||||
| `fable_list_tasks` | List tasks, filter by status or project |
|
|
||||||
| `fable_get_task` | Fetch a task by ID |
|
|
||||||
| `fable_create_task` | Create a new task |
|
|
||||||
| `fable_update_task` | Update a task |
|
|
||||||
| `fable_add_task_log` | Append a work log entry to a task |
|
|
||||||
| `fable_list_projects` | List all projects |
|
|
||||||
| `fable_get_project` | Fetch a project with milestone summary |
|
|
||||||
| `fable_create_project` | Create a project |
|
|
||||||
| `fable_update_project` | Update a project |
|
|
||||||
| `fable_list_milestones` | List milestones for a project |
|
|
||||||
| `fable_create_milestone` | Create a milestone |
|
|
||||||
| `fable_update_milestone` | Update a milestone |
|
|
||||||
| `fable_search` | Semantic search over notes and tasks |
|
|
||||||
| `fable_list_conversations` | List MCP chat conversations |
|
|
||||||
| `fable_send_message` | Send a message to Fable's LLM |
|
|
||||||
| `fable_get_app_logs` | Fetch application logs (admin key required) |
|
|
||||||
|
|
||||||
### Development Notes
|
| Family | Examples | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| Notes | `create_note`, `get_note`, `update_note`, `delete_note`, `list_notes` | Free-form knowledge |
|
||||||
|
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
|
||||||
|
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
||||||
|
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
|
||||||
|
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
|
||||||
|
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
|
||||||
|
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||||
|
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
||||||
|
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
|
||||||
|
|
||||||
The `fable-mcp` package lives in `fable-mcp/` in this repository. The Docker build compiles it into a wheel at `/app/dist/` so it can be served for download without requiring the source tree at runtime.
|
Server-level usage guidance — when to reach for each entity, the
|
||||||
|
recall-before-acting reflex, and the rulebook conventions — is delivered to the
|
||||||
To build the wheel locally:
|
client automatically via the MCP server's `instructions` block (defined in
|
||||||
```bash
|
`src/scribe/mcp/server.py`).
|
||||||
cd fable-mcp
|
|
||||||
pip install build hatchling
|
|
||||||
python -m build --wheel .
|
|
||||||
```
|
|
||||||
|
|||||||
+135
-172
@@ -1,243 +1,206 @@
|
|||||||
# API Reference
|
# API Reference
|
||||||
|
|
||||||
All endpoints require login (session cookie or `Authorization: Bearer <api-key>`) unless marked **(public)**.
|
All endpoints are JSON over HTTP under `/api`, and require login unless marked
|
||||||
|
**(public)**. Browser sessions authenticate with a cookie; programmatic clients use an
|
||||||
|
`fmcp_` API key as `Authorization: Bearer <key>` (see
|
||||||
|
[API Keys & MCP](api-keys-and-mcp.md)). Claude reaches the same data through the MCP
|
||||||
|
endpoint at `/mcp`, not these REST routes.
|
||||||
|
|
||||||
## Health
|
## Health
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/health` | Health check **(public)** |
|
| GET | `/api/health` | Health check **(public)** |
|
||||||
|
| GET | `/api/version` | App version **(public)** |
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/auth/status` | `{has_users, registration_open, oauth_enabled, local_auth_enabled}` **(public)** |
|
| GET | `/api/auth/status` | `{has_users, registration_open, oauth_enabled, local_auth_enabled}` **(public)** |
|
||||||
| POST | `/api/auth/register` | Register new user (first user becomes admin; 403 if registration closed or local auth disabled) |
|
| POST | `/api/auth/register` | Register (first user becomes admin) |
|
||||||
| POST | `/api/auth/login` | Login with username/password (403 if local auth disabled) |
|
| POST | `/api/auth/login` | Login with username/password |
|
||||||
| POST | `/api/auth/logout` | Clear session |
|
| POST | `/api/auth/logout` | Clear session |
|
||||||
| GET | `/api/auth/me` | Current user info (includes `has_password: bool`) |
|
| GET | `/api/auth/me` | Current user info |
|
||||||
| PUT | `/api/auth/password` | Change password `{current_password, new_password}` |
|
| PUT | `/api/auth/password` | Change password |
|
||||||
| PUT | `/api/auth/email` | Change email `{email, password?}` (password required only for local-auth users) |
|
| PUT | `/api/auth/email` | Change email |
|
||||||
| POST | `/api/auth/invalidate-sessions` | Bump `session_version` — evicts all other sessions, keeps current alive |
|
| POST | `/api/auth/invalidate-sessions` | Evict all other sessions |
|
||||||
| POST | `/api/auth/forgot-password` | Send password reset email `{email}` |
|
| POST | `/api/auth/forgot-password` | Send password-reset email |
|
||||||
| POST | `/api/auth/reset-password` | Reset password with token `{token, new_password}` |
|
| POST | `/api/auth/reset-password` | Reset password with token |
|
||||||
| GET | `/api/auth/oauth/login` | Initiate OIDC PKCE flow → redirect to provider |
|
| GET | `/api/auth/oauth/login` | Begin OIDC (PKCE) flow |
|
||||||
| GET | `/api/auth/oauth/callback` | OIDC callback — exchange code, find/create user, redirect to `/` |
|
| GET | `/api/auth/oauth/callback` | OIDC callback |
|
||||||
| GET | `/api/auth/invitation/:token` | Validate invitation token **(public)** |
|
| GET | `/api/auth/invitation/:token` | Validate an invite **(public)** |
|
||||||
| POST | `/api/auth/register-with-invite` | Register with token `{token, username, password}` **(public)** |
|
| POST | `/api/auth/register-with-invite` | Register with a token **(public)** |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/notes` | List notes. Params: `q`, `tag`, `sort`, `order`, `limit`, `offset`, `project_id`, `milestone_id`, `parent_id`, `type` (`note`/`task`/`all`) |
|
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`, `project_id`, `milestone_id`, `parent_id`) |
|
||||||
| POST | `/api/notes` | Create note `{title, body, tags?, status?, priority?, due_date?, project_id?, milestone_id?, parent_id?}` |
|
| POST | `/api/notes` | Create note |
|
||||||
| GET | `/api/notes/tags` | All tags (param: `q` for filter) |
|
| GET | `/api/notes/tags` | All tags (param: `q`) |
|
||||||
| POST | `/api/notes/suggest-tags` | LLM tag suggestions `{title, body, current_tags?}` → `{suggested_tags}` |
|
| POST | `/api/notes/:id/append-tag` | Add a tag `{tag}` |
|
||||||
| POST | `/api/notes/link-suggestions` | Detect note titles as plain text in body `{body, project_id, exclude_note_id}` → `[{note_id, title, count}]` |
|
| POST | `/api/notes/link-suggestions` | Detect note titles as plain text → wikilink candidates |
|
||||||
| GET | `/api/notes/by-title` | Resolve note by exact title (param: `title`) |
|
| GET | `/api/notes/by-title` | Resolve note by exact title |
|
||||||
| POST | `/api/notes/resolve-title` | Get-or-create note by title `{title}` (wikilink click) |
|
| POST | `/api/notes/resolve-title` | Get-or-create by title (wikilink click) |
|
||||||
| GET | `/api/notes/:id` | Get single note |
|
| GET / PUT / PATCH / DELETE | `/api/notes/:id` | Read / replace / patch / delete |
|
||||||
| PUT | `/api/notes/:id` | Full update |
|
| POST | `/api/notes/:id/convert-to-task` | Note → task |
|
||||||
| PATCH | `/api/notes/:id` | Partial update (same fields as PUT) |
|
| POST | `/api/notes/:id/convert-to-note` | Task → note |
|
||||||
| DELETE | `/api/notes/:id` | Delete note |
|
|
||||||
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` |
|
|
||||||
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` |
|
|
||||||
| POST | `/api/notes/:id/append-tag` | Add tag `{tag}` → updated note |
|
|
||||||
| GET | `/api/notes/:id/backlinks` | Notes/tasks with `[[Title]]` references to this note |
|
| GET | `/api/notes/:id/backlinks` | Notes/tasks with `[[Title]]` references to this note |
|
||||||
| GET | `/api/notes/:id/versions` | List note version history |
|
| GET | `/api/notes/:id/versions` | Version history |
|
||||||
| GET | `/api/notes/:id/versions/:vid` | Get a specific version |
|
| GET | `/api/notes/:id/versions/:vid` | A specific version |
|
||||||
| GET | `/api/notes/:id/draft` | Get current AI draft |
|
| POST | `/api/notes/:id/versions/:vid/pin` | Pin a version |
|
||||||
| PUT | `/api/notes/:id/draft` | Save AI draft |
|
| GET / PUT / DELETE | `/api/notes/:id/draft` | Unsaved-edit draft (restore across page loads) |
|
||||||
| DELETE | `/api/notes/:id/draft` | Delete AI draft |
|
| GET | `/api/notes/graph` | Knowledge-graph nodes/edges |
|
||||||
| POST | `/api/notes/assist` | Launch AI assist generation → 202 `{body, target_section?, instruction, whole_doc?}` |
|
|
||||||
| GET | `/api/notes/assist/stream` | SSE stream for assist (Last-Event-ID reconnect; events: `chunk`, `done`, `error`) |
|
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/tasks` | List tasks. Params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset` |
|
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `overdue`, `sort`, `order`, `limit`, `offset`) |
|
||||||
| POST | `/api/tasks` | Create task (accepts `project` name string → resolved to `project_id`) |
|
| POST | `/api/tasks` | Create task (accepts a `project` name string → resolved to `project_id`) |
|
||||||
| GET | `/api/tasks/:id` | Get task (includes `parent_title`) |
|
| POST | `/api/tasks/planning` | Start a plan (milestone + steps) |
|
||||||
| PUT | `/api/tasks/:id` | Full update |
|
| GET / PUT / PATCH / DELETE | `/api/tasks/:id` | Read / update / delete |
|
||||||
| PATCH | `/api/tasks/:id/status` | Quick status update `{status}` |
|
| PATCH | `/api/tasks/:id/status` | Quick status update `{status}` |
|
||||||
| DELETE | `/api/tasks/:id` | Delete task |
|
| GET | `/api/tasks/:id/recurrence-preview` | Preview next recurrence occurrences |
|
||||||
| GET | `/api/tasks/:id/logs` | List work logs |
|
|
||||||
| POST | `/api/tasks/:id/logs` | Create log `{content, duration_minutes?}` |
|
|
||||||
| PATCH | `/api/tasks/:id/logs/:log_id` | Update log |
|
|
||||||
| DELETE | `/api/tasks/:id/logs/:log_id` | Delete log |
|
|
||||||
|
|
||||||
## Projects & Milestones
|
**Task work logs**
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/projects` | List projects (owned + shared) |
|
| GET / POST | `/api/tasks/:id/logs` | List / append a work log `{content, duration_minutes?}` |
|
||||||
| POST | `/api/projects` | Create project |
|
| PATCH / DELETE | `/api/tasks/:id/logs/:log_id` | Update / delete a log |
|
||||||
| GET | `/api/projects/:id` | Get project with `milestone_summary` |
|
|
||||||
| PATCH | `/api/projects/:id` | Update project |
|
## Projects, Milestones, Systems, Issues
|
||||||
| DELETE | `/api/projects/:id` | Delete project |
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
||||||
|
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`) / update / delete |
|
||||||
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
||||||
| GET | `/api/projects/:id/milestones` | List milestones |
|
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
||||||
| POST | `/api/projects/:id/milestones` | Create milestone |
|
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
|
||||||
| PATCH | `/api/projects/:id/milestones/:mid` | Update milestone |
|
| GET | `/api/projects/:id/milestones/:mid/tasks` | Tasks in a milestone |
|
||||||
| DELETE | `/api/projects/:id/milestones/:mid` | Delete milestone |
|
| GET / POST | `/api/projects/:id/systems` | List / create systems |
|
||||||
|
| GET / PATCH / DELETE | `/api/projects/:id/systems/:sid` | Read / update / delete |
|
||||||
|
| GET | `/api/projects/:id/systems/:sid/records` | Records linked to a system |
|
||||||
|
| GET | `/api/projects/:id/issues` | Project issues |
|
||||||
|
|
||||||
|
## Knowledge browse
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/knowledge` | Unified note/task/plan/process feed (params: `type`, `tags`, `sort`, `q`, `limit`, `offset`) |
|
||||||
|
| GET | `/api/knowledge/ids` | ID-only page (two-tier pagination) |
|
||||||
|
| GET | `/api/knowledge/batch` | Fetch items by id |
|
||||||
|
| GET | `/api/knowledge/tags` | Tags in scope |
|
||||||
|
| GET | `/api/knowledge/counts` | Per-type counts |
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/search` | Semantic + keyword search over notes/tasks (params: `q`, `type`, `limit`) |
|
||||||
|
|
||||||
|
## Rulebooks and rules
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET / POST | `/api/rulebooks` | List / create rulebooks |
|
||||||
|
| GET / PATCH / DELETE | `/api/rulebooks/:id` | Read / update / delete |
|
||||||
|
| GET / POST | `/api/rulebooks/:id/topics` | List / create topics |
|
||||||
|
| PATCH / DELETE | `/api/rulebook-topics/:tid` | Edit / delete a topic |
|
||||||
|
| GET | `/api/rules` | List rules |
|
||||||
|
| POST | `/api/rulebook-topics/:tid/rules` | Add a rule to a topic |
|
||||||
|
| GET / PATCH / DELETE | `/api/rules/:id` | Read / update / delete a rule |
|
||||||
|
| POST | `/api/projects/:id/rulebook-subscriptions` | Subscribe a project to a rulebook |
|
||||||
|
| GET | `/api/projects/:id/rules` | Applicable rules for a project |
|
||||||
|
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
|
||||||
|
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
|
||||||
|
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
|
||||||
|
|
||||||
## Sharing
|
## Sharing
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/projects/:id/shares` | List project shares |
|
| GET / POST | `/api/projects/:id/shares` | List / add project shares `{user_id?, group_id?, permission}` |
|
||||||
| POST | `/api/projects/:id/shares` | Create project share `{user_id?, group_id?, permission}` |
|
| PATCH / DELETE | `/api/projects/:id/shares/:sid` | Update permission / remove |
|
||||||
| PATCH | `/api/projects/:id/shares/:sid` | Update permission |
|
| GET / POST | `/api/notes/:id/shares` | List / add note shares |
|
||||||
| DELETE | `/api/projects/:id/shares/:sid` | Remove share |
|
| PATCH / DELETE | `/api/notes/:id/shares/:sid` | Update permission / remove |
|
||||||
| GET | `/api/notes/:id/shares` | List note shares |
|
|
||||||
| POST | `/api/notes/:id/shares` | Create note share |
|
|
||||||
| PATCH | `/api/notes/:id/shares/:sid` | Update permission |
|
|
||||||
| DELETE | `/api/notes/:id/shares/:sid` | Remove share |
|
|
||||||
| GET | `/api/shared-with-me` | All resources shared with the current user |
|
| GET | `/api/shared-with-me` | All resources shared with the current user |
|
||||||
|
|
||||||
## Groups
|
## Groups
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/groups` | List all groups (admin only) |
|
| GET / POST | `/api/groups` | List / create groups |
|
||||||
| POST | `/api/groups` | Create group `{name, description?}` |
|
| GET / PATCH / DELETE | `/api/groups/:id` | Read / update / delete |
|
||||||
| PATCH | `/api/groups/:id` | Update group |
|
| GET / POST | `/api/groups/:id/members` | List / add members `{user_id, role}` |
|
||||||
| DELETE | `/api/groups/:id` | Delete group |
|
| PATCH / DELETE | `/api/groups/:id/members/:uid` | Update role / remove |
|
||||||
| GET | `/api/groups/:id/members` | List members |
|
|
||||||
| POST | `/api/groups/:id/members` | Add member `{user_id, role}` |
|
|
||||||
| PATCH | `/api/groups/:id/members/:uid` | Update member role |
|
|
||||||
| DELETE | `/api/groups/:id/members/:uid` | Remove member |
|
|
||||||
|
|
||||||
## Chat
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
|
|
||||||
| POST | `/api/chat/conversations` | Create conversation `{title?, model?}` |
|
|
||||||
| POST | `/api/chat/conversations/bulk-delete` | Delete multiple conversations `{ids: number[]}` |
|
|
||||||
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
|
|
||||||
| PATCH | `/api/chat/conversations/:id` | Update title or model |
|
|
||||||
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
|
|
||||||
| POST | `/api/chat/conversations/:id/messages` | Start generation → 202. Body: `{content, context_note_id?, include_note_ids?, rag_project_id?, workspace_project_id?, think?}` |
|
|
||||||
| GET | `/api/chat/conversations/:id/generation/stream` | SSE stream (Last-Event-ID reconnect; events: `context`, `chunk`, `tool_call`, `status`, `done`, `error`) |
|
|
||||||
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation |
|
|
||||||
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as note |
|
|
||||||
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation → note |
|
|
||||||
| GET | `/api/chat/status` | Ollama availability + model state `{ollama, model, default_model}` |
|
|
||||||
| GET | `/api/chat/models` | List installed Ollama models (includes `loaded: bool`, `modified_at`) |
|
|
||||||
| POST | `/api/chat/models/pull` | Pull model (SSE NDJSON progress) `{model}` |
|
|
||||||
| POST | `/api/chat/models/delete` | Delete model `{model}` |
|
|
||||||
| GET | `/api/chat/ps` | Currently loaded (hot) models |
|
|
||||||
| POST | `/api/chat/warm` | Pre-load model into VRAM `{model}` → 202 |
|
|
||||||
|
|
||||||
## Quick Capture
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | `/api/quick-capture` | Classify + create item from natural language `{text}` → `{success, type, message, data}` |
|
|
||||||
|
|
||||||
## Search
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
|
|
||||||
|
|
||||||
## Journal
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/api/journal/config` | Get journal configuration (locations, temp_unit, prep schedule) |
|
|
||||||
| PUT | `/api/journal/config` | Save journal configuration; live-reschedules the prep job |
|
|
||||||
| GET | `/api/journal/today` | Get/create today's journal conversation + messages |
|
|
||||||
| GET | `/api/journal/day/:iso` | Get a specific day's journal conversation (read-only) |
|
|
||||||
| GET | `/api/journal/days` | List dates with journal content, newest first |
|
|
||||||
| POST | `/api/journal/trigger-prep` | Force-regenerate today's prep (or `{date}` for a specific day) |
|
|
||||||
| GET | `/api/journal/weather` | Cached weather rows; auto-refreshes stale rows in the background |
|
|
||||||
| GET | `/api/journal/weather/current` | Live current conditions for the primary configured location |
|
|
||||||
| POST | `/api/journal/weather/refresh` | Manual refresh of all configured locations |
|
|
||||||
| POST | `/api/journal/weather/geocode` | Geocode place name `{query}` → `{lat, lon, label}` |
|
|
||||||
| POST | `/api/journal/moments/:id/update` | Update a recorded moment |
|
|
||||||
| DELETE | `/api/journal/moments/:id` | Delete a moment |
|
|
||||||
|
|
||||||
## Settings
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/api/settings` | All settings as `{key: value}` |
|
|
||||||
| PUT | `/api/settings` | Update settings `{key: value, ...}` |
|
|
||||||
| GET | `/api/settings/models` | Installed models + defaults |
|
|
||||||
| GET | `/api/settings/search` | Proxy SearXNG search (params: `q`) |
|
|
||||||
|
|
||||||
## API Keys
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/api/api-keys` | List user's API keys |
|
|
||||||
| POST | `/api/api-keys` | Create key `{name, scope}` → `{key, ...}` (key shown once) |
|
|
||||||
| DELETE | `/api/api-keys/:id` | Revoke key |
|
|
||||||
|
|
||||||
## Fable MCP Distribution
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/api/fable-mcp/info` | `{available: bool, filename: string\|null}` |
|
|
||||||
| GET | `/api/fable-mcp/download` | Download wheel file |
|
|
||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/notifications` | List notifications |
|
| GET | `/api/notifications` | List in-app notifications |
|
||||||
| GET | `/api/notifications/count` | Unread count |
|
| GET | `/api/notifications/count` | Unread count |
|
||||||
| POST | `/api/notifications/:id/read` | Mark read |
|
| POST | `/api/notifications/:id/read` | Mark one read |
|
||||||
| POST | `/api/notifications/read-all` | Mark all read |
|
| POST | `/api/notifications/read-all` | Mark all read |
|
||||||
|
|
||||||
## Push
|
## Profile and Settings
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/push/vapid-public-key` | VAPID public key for subscription |
|
| GET / PUT | `/api/profile` | Read / update the per-user profile |
|
||||||
| POST | `/api/push/subscribe` | Register push subscription |
|
| GET / PUT | `/api/settings` | Read / update key-value settings |
|
||||||
| DELETE | `/api/push/subscribe` | Unregister push subscription |
|
| GET | `/api/settings/search` | SearXNG configuration status |
|
||||||
|
|
||||||
## Images
|
## API keys
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/images/:id` | Serve cached image **(no auth required — IDs are opaque SHA-256)** |
|
| GET / POST | `/api/api-keys` | List / create `fmcp_` keys (key shown once) |
|
||||||
|
| DELETE | `/api/api-keys/:id` | Revoke a key |
|
||||||
|
|
||||||
## Users
|
## Plugin (Claude Code)
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/users/search` | Search users by username/email prefix (param: `q`, min 2 chars, excludes self) |
|
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
|
||||||
|
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
|
||||||
|
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
||||||
|
| GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL |
|
||||||
|
|
||||||
## Export
|
## Dashboard, Export, Trash, Users
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/export` | Export data. Params: `format=markdown` (ZIP with `.md` + YAML frontmatter) or `format=json` |
|
| GET | `/api/dashboard` | Home dashboard payload |
|
||||||
|
| GET | `/api/export` | Personal export (`format=markdown` ZIP or `format=json`) |
|
||||||
|
| GET | `/api/trash` | List trashed items, grouped by delete batch |
|
||||||
|
| POST | `/api/trash/:batch/restore` | Restore a batch |
|
||||||
|
| DELETE | `/api/trash/:batch` | Purge a batch (irreversible) |
|
||||||
|
| GET | `/api/users/search` | Search users by prefix (for sharing) |
|
||||||
|
|
||||||
## Admin
|
## Admin
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data; full requires admin) |
|
| GET | `/api/admin/backup` | Export backup (format v4; `?scope=user` for own data) |
|
||||||
| POST | `/api/admin/restore` | Restore from JSON backup |
|
| POST | `/api/admin/restore` | Restore from a backup |
|
||||||
| GET | `/api/admin/users` | List all users |
|
| GET / DELETE | `/api/admin/users` · `/api/admin/users/:id` | List / delete users |
|
||||||
| DELETE | `/api/admin/users/:id` | Delete user (cannot delete self) |
|
| GET / PUT | `/api/admin/registration` | Get / toggle registration |
|
||||||
| GET | `/api/admin/registration` | Get registration open/closed state |
|
| GET / PUT | `/api/admin/smtp` · POST `/api/admin/smtp/test` | SMTP config + test email |
|
||||||
| PUT | `/api/admin/registration` | Toggle registration `{open: bool}` |
|
| GET | `/api/admin/logs` · `/api/admin/logs/stats` | Log entries + category counts |
|
||||||
| POST | `/api/admin/invitations` | Create invitation `{email}` → sends email |
|
| GET / PUT | `/api/admin/base-url` | Get / set the public base URL |
|
||||||
| GET | `/api/admin/invitations` | List pending invitations |
|
| GET / PUT | `/api/admin/db-maintenance` (+ `/health`, POST `/run`) | VACUUM schedule, health, manual run |
|
||||||
| DELETE | `/api/admin/invitations/:id` | Revoke invitation |
|
| POST / GET / DELETE | `/api/admin/invitations` (+ `/:id`) | Create / list / revoke invite links |
|
||||||
| GET | `/api/admin/logs` | Log entries. Params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset` |
|
|
||||||
| GET | `/api/admin/logs/stats` | Log category counts |
|
## Scribe MCP
|
||||||
| GET | `/api/admin/base-url` | Get base URL setting |
|
|
||||||
| PUT | `/api/admin/base-url` | Set base URL `{base_url}` |
|
Claude clients connect to the built-in MCP server at `POST /mcp` (streamable HTTP,
|
||||||
| GET | `/api/admin/smtp` | Get SMTP config (password masked) |
|
Bearer auth with an `fmcp_` key), served by `src/scribe/mcp/`. It is not a REST
|
||||||
| PUT | `/api/admin/smtp` | Save SMTP config |
|
surface — it exposes the same data as typed tools (`create_note`, `create_task`,
|
||||||
| POST | `/api/admin/smtp/test` | Send test email `{recipient}` |
|
`start_planning`, `search`, `enter_project`, `list_always_on_rules`, …) with
|
||||||
|
server-level usage guidance delivered in the MCP `instructions` block. See
|
||||||
|
[API Keys & MCP](api-keys-and-mcp.md).
|
||||||
|
|||||||
+8
-10
@@ -20,7 +20,7 @@
|
|||||||
│ Docker Compose │
|
│ Docker Compose │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────────┐ ┌────────────┐ │
|
│ ┌──────────────────────┐ ┌────────────┐ │
|
||||||
│ │ fabledassistant │ │ ollama │ │
|
│ │ scribe │ │ ollama │ │
|
||||||
│ │ ┌────────────────┐ │ │ │ │
|
│ │ ┌────────────────┐ │ │ │ │
|
||||||
│ │ │ Quart Server │ │ │ LLM API │ │
|
│ │ │ Quart Server │ │ │ LLM API │ │
|
||||||
│ │ │ ┌──────────┐ │ │ │ │ │
|
│ │ │ ┌──────────┐ │ │ │ │ │
|
||||||
@@ -43,22 +43,20 @@
|
|||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
fabledassistant/
|
scribe/
|
||||||
├── docker-compose.yml # Development stack
|
├── docker-compose.yml # Development stack
|
||||||
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
||||||
├── Dockerfile # Multi-stage build (Node → Python)
|
├── Dockerfile # Multi-stage build (Node → Python)
|
||||||
├── alembic/ # Database migrations
|
├── alembic/ # Database migrations
|
||||||
│ └── versions/ # Migration files (idempotent raw SQL)
|
│ └── versions/ # Migration files (idempotent raw SQL)
|
||||||
├── fable-mcp/ # Fable MCP server package
|
├── src/scribe/
|
||||||
│ └── fable_mcp/
|
|
||||||
│ ├── server.py # FastMCP tool registrations
|
|
||||||
│ ├── client.py # FableClient (httpx wrapper)
|
|
||||||
│ └── tools/ # Tool modules (notes, tasks, projects, …)
|
|
||||||
├── src/fabledassistant/
|
|
||||||
│ ├── app.py # Quart app factory + blueprint registration
|
│ ├── app.py # Quart app factory + blueprint registration
|
||||||
│ ├── config.py # Config class (reads env vars)
|
│ ├── config.py # Config class (reads env vars)
|
||||||
│ ├── auth.py # login_required decorator, session checks
|
│ ├── auth.py # login_required decorator, session checks
|
||||||
│ ├── models/ # SQLAlchemy models
|
│ ├── models/ # SQLAlchemy models
|
||||||
|
│ ├── mcp/ # In-app MCP server (FastMCP, mounted at /mcp)
|
||||||
|
│ │ ├── server.py # FastMCP instance + instructions + Quart mount
|
||||||
|
│ │ └── tools/ # Tool modules (notes, tasks, projects, rulebooks, …)
|
||||||
│ ├── routes/ # API blueprints (one file per resource)
|
│ ├── routes/ # API blueprints (one file per resource)
|
||||||
│ ├── services/ # Business logic (access, llm, tools, sharing, …)
|
│ ├── services/ # Business logic (access, llm, tools, sharing, …)
|
||||||
│ └── static/ # Built Vue SPA (generated at Docker build time)
|
│ └── static/ # Built Vue SPA (generated at Docker build time)
|
||||||
@@ -169,7 +167,7 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
|||||||
|
|
||||||
## Detailed File Reference
|
## Detailed File Reference
|
||||||
|
|
||||||
### Backend (`src/fabledassistant/`)
|
### Backend (`src/scribe/`)
|
||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|------|---------------|
|
|------|---------------|
|
||||||
@@ -202,7 +200,7 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
|||||||
| `routes/images.py` | Serve cached images at `/api/images/<id>` |
|
| `routes/images.py` | Serve cached images at `/api/images/<id>` |
|
||||||
| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download |
|
| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download |
|
||||||
| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) |
|
| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) |
|
||||||
| `routes/fable_mcp_dist.py` | `GET /api/fable-mcp/info` + `GET /api/fable-mcp/download` — package distribution |
|
| `mcp/server.py` | Mounts the in-app FastMCP server at `/mcp` (streamable HTTP, Bearer auth) |
|
||||||
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
|
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
|
||||||
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
|
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
|
||||||
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
|
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Configuration is via environment variables. The `docker-compose.yml` file sets d
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/fabledassistant` | PostgreSQL async connection string |
|
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/scribe` | PostgreSQL async connection string |
|
||||||
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
|
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
|
||||||
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
|
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
|
||||||
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
||||||
@@ -58,12 +58,6 @@ See [sso-oauth.md](sso-oauth.md) for provider-specific setup.
|
|||||||
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
|
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
|
||||||
| `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) |
|
| `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) |
|
||||||
|
|
||||||
### Fable MCP Distribution
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `FABLE_MCP_DIST_DIR` | `/app/dist` | Directory where the bundled `fable-mcp` wheel is placed at build time |
|
|
||||||
|
|
||||||
## Docker Compose Setup
|
## Docker Compose Setup
|
||||||
|
|
||||||
### Development (`docker-compose.yml`)
|
### Development (`docker-compose.yml`)
|
||||||
@@ -91,7 +85,7 @@ The production compose file adds:
|
|||||||
```bash
|
```bash
|
||||||
# Create Docker secrets
|
# Create Docker secrets
|
||||||
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
|
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
|
||||||
echo "postgresql+asyncpg://fabled:strongpassword@db/fabledassistant" | docker secret create fabled_db_url -
|
echo "postgresql+asyncpg://fabled:strongpassword@db/scribe" | docker secret create fabled_db_url -
|
||||||
|
|
||||||
# Deploy
|
# Deploy
|
||||||
docker stack deploy -c docker-compose.prod.yml fabled
|
docker stack deploy -c docker-compose.prod.yml fabled
|
||||||
|
|||||||
+4
-4
@@ -93,7 +93,7 @@ config on the runner host.
|
|||||||
|
|
||||||
### Docker Registry
|
### Docker Registry
|
||||||
|
|
||||||
Images pushed to: `git.fabledsword.com/bvandeusen/fabledassistant`
|
Images pushed to: `git.fabledsword.com/bvandeusen/scribe`
|
||||||
Cache tag: `:cache` (reduces build time ~80%)
|
Cache tag: `:cache` (reduces build time ~80%)
|
||||||
|
|
||||||
Required secrets (repo → Settings → Secrets → Actions):
|
Required secrets (repo → Settings → Secrets → Actions):
|
||||||
@@ -140,8 +140,8 @@ Current migration sequence (all idempotent raw SQL):
|
|||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
- Services: `async with async_session() as session:` — import from `fabledassistant.models`
|
- Services: `async with async_session() as session:` — import from `scribe.models`
|
||||||
- No `fabledassistant.database` module
|
- No `scribe.database` module
|
||||||
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
|
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
|
||||||
- All business logic in `services/`; routes are thin wrappers
|
- All business logic in `services/`; routes are thin wrappers
|
||||||
- Permission checks via `services/access.py` — never inline ownership checks in routes
|
- Permission checks via `services/access.py` — never inline ownership checks in routes
|
||||||
@@ -164,7 +164,7 @@ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
|
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
|
||||||
Scopes: feature area (e.g. `chat`, `briefing`, `fable-mcp`, `notes`)
|
Scopes: feature area (e.g. `chat`, `journal`, `mcp`, `notes`)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
+118
-105
@@ -1,155 +1,168 @@
|
|||||||
# Features
|
# Features
|
||||||
|
|
||||||
|
Scribe is a self-hosted work system-of-record for software projects, built to be
|
||||||
|
driven by Claude Code. There is **no in-app LLM** — Claude is the sole assistant,
|
||||||
|
reaching Scribe through a built-in MCP endpoint and a bundled Claude Code plugin. The
|
||||||
|
web UI is a clean surface for humans to read and edit the same data.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks.
|
Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold,
|
||||||
|
italic, lists, code blocks, and task checklists render inline. A slash-command menu
|
||||||
|
(`/`) inserts common blocks.
|
||||||
|
|
||||||
**Wikilinks** — Link notes with `[[Title]]` or `[[Title|Display Text]]` syntax. Clicking a wikilink navigates to (or auto-creates) the referenced note. The editor suggests existing note titles as candidate links while typing `[[`. Backlinks appear in the note viewer sidebar.
|
- **Wikilinks** — Link notes with `[[Title]]` or `[[Title|Display Text]]`. Clicking
|
||||||
|
navigates to (or auto-creates) the referenced note; the editor suggests existing
|
||||||
|
titles while you type `[[`. Backlinks appear in the note viewer sidebar.
|
||||||
|
- **Tags** — First-class `ARRAY[text]` column with autocomplete. Hierarchical tags
|
||||||
|
(`area/backend`) supported — filtering by `area` matches all `area/*` children.
|
||||||
|
- **Version history** — Every body edit snapshots a version (up to 20 per note).
|
||||||
|
Browse, diff, and restore from the editor's History panel.
|
||||||
|
- **Draft recovery** — In-progress edits persist across page loads and are restored
|
||||||
|
when you reopen a note.
|
||||||
|
- **Convert freely** — Turn a note into a task (sets `status=todo`) or back again.
|
||||||
|
|
||||||
**Tags** — First-class `ARRAY[text]` column. Tag autocomplete in the editor sidebar suggests existing tags. Hierarchical tags (`project/webapp`) supported — filtering by `project` matches all `project/*` children. Tags are browsable via the knowledge graph.
|
## Tasks and Issues
|
||||||
|
|
||||||
**Version history** — Every body edit snapshots a version (up to 20 per note). Browse and restore from the editor's History panel. Diff view shows changes against the current body.
|
Tasks carry status (`todo` → `in_progress` → `done`/`cancelled`), priority
|
||||||
|
(`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task
|
||||||
|
(sub-tasks). Notes and tasks share one model — a task is a note with a status.
|
||||||
|
|
||||||
**AI writing assist** — Select a passage or work on the full document. Give an instruction ("make this more concise", "add examples"). The assistant streams a proposal; a diff view shows changes to accept or reject. Drafts persist across page loads.
|
- **Work logs** — Append timestamped progress entries (with optional duration) to a
|
||||||
|
task without rewriting its body; shown chronologically in the task view.
|
||||||
**Link suggestions** — The editor detects note titles appearing as plain text in the body and suggests converting them to wikilinks.
|
- **Issues** — A task whose `kind` is corrective: a problem you fixed or are fixing,
|
||||||
|
with symptom → root cause → fix in the body. An issue can link the task it arose
|
||||||
## Tasks
|
from and the System(s) it touches.
|
||||||
|
- **Recurring tasks** — An interval or calendar recurrence rule spawns the next
|
||||||
Tasks carry status (`todo` → `in_progress` → `done`), priority (`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task (sub-tasks).
|
occurrence when a task is completed (a background job drains due spawns).
|
||||||
|
- **Sub-tasks** — Any task can have children via `parent_id`; the viewer shows them
|
||||||
**Task work logs** — Append progress log entries to a task with optional duration. Time tracking is visible in the task editor sidebar.
|
inline.
|
||||||
|
|
||||||
**Sub-tasks** — Any task can have child tasks via `parent_id`. The task viewer shows sub-tasks inline.
|
|
||||||
|
|
||||||
**Convert freely** — Convert a note to a task (sets `status=todo`) or a task back to a note from the viewer toolbar.
|
|
||||||
|
|
||||||
## Projects and Milestones
|
## Projects and Milestones
|
||||||
|
|
||||||
**Projects** — Group related notes and tasks. Each project has a title, description, goal, status (`active`/`completed`/`archived`), and a colour.
|
- **Projects** — Group related notes and tasks. Title, description, goal, status
|
||||||
|
(`active`/`paused`/`completed`/`archived`), and a colour.
|
||||||
|
- **Milestones** — Ordered stages within a project. A milestone is also the home of a
|
||||||
|
**plan** — its body holds the design (Goal/Approach/Verification) and its child
|
||||||
|
tasks are the steps. Completion percentage is shown on the project page.
|
||||||
|
- **Kanban view** — `/projects/:id` groups tasks by milestone in a column layout with
|
||||||
|
status-advance buttons on the cards.
|
||||||
|
|
||||||
**Milestones** — Ordered stages within a project. Tasks are assigned to milestones. Milestone completion percentage shown on the project page.
|
## Systems
|
||||||
|
|
||||||
**Kanban view** — `/projects/:id` groups tasks by milestone in a kanban-style column layout with status-advance buttons directly on cards (→ advance, ✓ complete).
|
A **System** is a per-project, reusable, self-describing subsystem or area (e.g.
|
||||||
|
"auth", "billing"). Associate any note, task, or issue with a System so research,
|
||||||
|
build-work, and fixes for the same area line up and recurring problem-spots surface.
|
||||||
|
|
||||||
**Project Workspace** — `/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically after tool calls.
|
## Rules and Rulebooks
|
||||||
|
|
||||||
|
Scribe stores the operator's engineering and workflow **rules** so Claude follows them
|
||||||
|
across sessions.
|
||||||
|
|
||||||
|
- **Rulebooks → topics → rules** — Rules are grouped by topic inside a rulebook.
|
||||||
|
- **Always-on rules** — A rulebook can be flagged always-on; its rules load at the
|
||||||
|
start of every session through the plugin's push channel.
|
||||||
|
- **Per-project scope** — A project subscribes to rulebooks, and can add
|
||||||
|
project-scoped rules or suppress individual inherited rules/topics.
|
||||||
|
|
||||||
|
## Stored Processes
|
||||||
|
|
||||||
|
Reusable saved prompts (a note with `note_type=process`) — e.g. a drift-audit or a
|
||||||
|
DRY pass. The bundled plugin syncs each Process into a local Claude Code skill stub
|
||||||
|
(`/scribe:sync`) that auto-surfaces by relevance and fetches the live procedure on
|
||||||
|
demand.
|
||||||
|
|
||||||
|
## Search and Knowledge Injection
|
||||||
|
|
||||||
|
- **Semantic search** — pgvector-backed similarity search over notes and tasks
|
||||||
|
(in-process `fastembed` embeddings; no external model).
|
||||||
|
- **Proactive knowledge-injection** — the plugin's `UserPromptSubmit` hook surfaces a
|
||||||
|
short, high-confidence menu of maybe-relevant note *titles* into Claude's context
|
||||||
|
each turn; Claude pulls a full body only when it judges it relevant. Gated so it
|
||||||
|
stays quiet on most turns and never repeats within a session.
|
||||||
|
|
||||||
## Knowledge Graph
|
## Knowledge Graph
|
||||||
|
|
||||||
`/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; invisible project hub nodes attract project members. Physics controls: repulsion, link distance, link strength, hub pull, gravity. Click any node to open a slide-in peek panel. Click a tag node to filter the notes list.
|
`/graph` renders notes, tasks, and tags as a D3 force-directed graph. Tag nodes
|
||||||
|
cluster notes that share tags; invisible project hubs attract project members. Physics
|
||||||
|
controls (repulsion, link distance/strength, hub pull, gravity); click a node to peek,
|
||||||
|
click a tag to filter.
|
||||||
|
|
||||||
## AI Chat
|
## Claude via MCP and the plugin
|
||||||
|
|
||||||
Full conversation history with SSE streaming. Features:
|
The whole store is reachable by Claude through a built-in **MCP endpoint at `/mcp`**
|
||||||
- **RAG** — Semantically relevant notes (≥ 0.60 cosine similarity) auto-injected as context. Notes 0.45–0.60 shown in sidebar as "Suggested."
|
(Bearer-auth with an API key). The **Scribe Claude Code plugin** (shipped in this
|
||||||
- **Attach notes** — Paperclip icon to include specific notes in context.
|
repo) wires it up:
|
||||||
- **RAG scope chip** — Pill above the input bar shows the current note scope. Click to switch: "Orphan notes only" (default — project notes stay out of general chat), any active project, or "All notes." Scope is persisted per conversation. The AI can also call `search_projects` and `set_rag_scope` mid-conversation to switch scope automatically; the chip pulses when this happens.
|
|
||||||
- **Tool calls** — The assistant can create/update notes, tasks, projects, milestones, search the web, check weather, read RSS, query calendar events, and more. Tool calls display inline with confirm/deny for creates.
|
|
||||||
- **Thinking mode** — Toggle extended reasoning for complex questions.
|
|
||||||
- **Abort** — Stop button cancels in-flight generation.
|
|
||||||
- **Message queue** — Messages sent while generation is in progress are queued and drained sequentially.
|
|
||||||
- **Save to note** — Save any assistant reply directly as a note.
|
|
||||||
- **Bulk delete** — Select and delete multiple conversations.
|
|
||||||
- **Retention** — Conversations auto-pruned after configurable days (default 90).
|
|
||||||
|
|
||||||
## Daily Journal
|
- a `SessionStart` hook that injects the operator's always-on rules + active-project
|
||||||
|
context so Scribe surfaces without being asked (fail-open if Scribe is unreachable);
|
||||||
|
- universal process-skills — writing-plans, systematic-debugging, verification,
|
||||||
|
brainstorming — that route their output into Scribe;
|
||||||
|
- your saved Processes auto-surfaced as skills.
|
||||||
|
|
||||||
`/journal` is a conversational daily surface — each day is a chat-style conversation seeded with an LLM-generated daily prep as the first assistant message. The prep pulls together today's tasks, calendar events, weather, recent moments, and active projects in flowing prose, then invites the user to continue the conversation throughout the day.
|
See [API Keys & MCP](api-keys-and-mcp.md).
|
||||||
|
|
||||||
**Schedule** — Daily prep generates at a configurable time (default 5:00am). The "day rollover hour" controls when the journal switches to a new day (default 4am — late-night entries 1–3am still count as the previous day). Scheduler catches up missed runs on startup.
|
|
||||||
|
|
||||||
**Right rail** — The journal view shows current weather conditions and upcoming events for the next two weeks alongside the conversation. Both surfaces draw from the same data the prep references.
|
|
||||||
|
|
||||||
**Configuration** — Settings → Profile:
|
|
||||||
- *Locations* section: home and work place-name inputs (geocoded on blur via Nominatim) and a temperature unit toggle (C/F)
|
|
||||||
- *Journal* section: prep auto-generate toggle, prep generation time, day rollover hour
|
|
||||||
- *About You* / *Interests* / *Work Schedule* / *Response Preferences* feed personalization into the prep's system prompt
|
|
||||||
|
|
||||||
**Weather** — Location-based forecast via Open-Meteo. Up to two named locations (home, work). Cached rows auto-refresh in the background when the journal page loads.
|
|
||||||
|
|
||||||
**What the assistant has learned** — The assistant maintains a per-user observation log + consolidated summary, generated from journal and chat conversations. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you over time.
|
|
||||||
|
|
||||||
## Web Research
|
|
||||||
|
|
||||||
The assistant can search the web (SearXNG) and fetch pages, synthesising findings into a structured multi-note research output: an index note with an executive summary and links to focused section notes. Each section covers a distinct aspect of the topic with cited sources. Falls back to a single note when outline generation fails. A lightweight `search_web` tool answers quick questions inline without saving. Requires `SEARXNG_URL` to be configured.
|
|
||||||
|
|
||||||
## Calendar
|
|
||||||
|
|
||||||
`/calendar` shows a full FullCalendar view (month, week, day). Click an empty slot to create an event; click an existing event to edit or delete it via a slide-over panel.
|
|
||||||
|
|
||||||
**Internal events store** — Events are stored in the app database (`events` table), making them available without any external calendar. Fields: title, description, start/end datetime, all-day toggle, location, colour.
|
|
||||||
|
|
||||||
**AI tools** — `create_event`, `list_events`, `search_events`, `update_event`, `delete_event` all operate on the internal store. Tool-call result cards in chat are clickable and open the same EventSlideOver for editing.
|
|
||||||
|
|
||||||
**HomeView widget** — The dashboard shows today's and the next 7 days' events as clickable cards above the hero project.
|
|
||||||
|
|
||||||
**CalDAV sync (optional)** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) in Settings → Integrations. Events sync bidirectionally via a `caldav_uid` field.
|
|
||||||
|
|
||||||
## Sharing and Collaboration
|
## Sharing and Collaboration
|
||||||
|
|
||||||
**Share** — Share any project or note/task with users or groups at `viewer`/`editor`/`admin` permission levels. Share button in the viewer/project toolbar opens a dialog.
|
- **Share** — Share any project, note, or task with users or groups at
|
||||||
|
`viewer`/`editor`/`admin` levels from the viewer/project toolbar.
|
||||||
|
- **Groups** — Admins create platform-wide groups and assign `member`/`owner` roles;
|
||||||
|
share a resource with a group in one action.
|
||||||
|
- **Shared with me** — `/shared` lists incoming shares with permission badges.
|
||||||
|
- **Notifications** — An in-app bell (unread count, polled) fires when a project or
|
||||||
|
note is shared with you or you're added to a group.
|
||||||
|
|
||||||
**Groups** — Admins create platform-wide groups and assign users `member`/`owner` roles. Share a resource with a group in one action.
|
Every read and mutation is scoped by owner + direct shares + group shares.
|
||||||
|
|
||||||
**Shared with me** — `/shared` lists all incoming shared projects and notes with permission badges.
|
|
||||||
|
|
||||||
**Notifications** — Bell icon in nav shows unread count (60s polling). Notifications generated for: project shared, note shared, added to group. Click navigates to the resource.
|
|
||||||
|
|
||||||
**Push notifications** — Web Push (VAPID) notifies when AI generation completes, even in another tab. Works over HTTPS only. Configurable per-user.
|
|
||||||
|
|
||||||
## Quick Capture
|
|
||||||
|
|
||||||
Quick capture from the Android app routes to the intent classifier. It creates notes, tasks, or projects based on content — using the user's configured model, not the hardcoded default.
|
|
||||||
|
|
||||||
## Data Export and Backup
|
## Data Export and Backup
|
||||||
|
|
||||||
- **Personal export** — Settings → Data: download all notes/tasks as a Markdown ZIP (with YAML frontmatter) or JSON array.
|
- **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML
|
||||||
- **Admin backup** — Full application backup (version 2): includes projects, milestones, task logs, AI drafts, note versions, push subscriptions. ID remapping on restore for cross-instance migration.
|
frontmatter) or a JSON array.
|
||||||
|
- **Admin backup** — full application backup/restore (format v4) with ID remapping on
|
||||||
|
restore for cross-instance migration.
|
||||||
|
|
||||||
## PWA
|
## Progressive Web App
|
||||||
|
|
||||||
Installable as a desktop or mobile app. Service worker caches the shell; push notifications are suppressed when the relevant tab is already focused. Works over HTTPS only in Firefox.
|
Installable as a desktop or mobile app; a service worker caches the shell.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Native email + password, plus optional **OIDC** sign-in (Authentik, Keycloak, etc.)
|
||||||
|
that links to a matching local account. Invite links, a registration toggle, password
|
||||||
|
reset, and session invalidation are included. See [SSO / OAuth](sso-oauth.md).
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
Settings are tabbed:
|
|
||||||
|
|
||||||
| Tab | Contents |
|
| Tab | Contents |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| General | Assistant name, default model, model management (pull/delete) |
|
| General | Instance preferences (key/value) |
|
||||||
| Account | Email change, password change, session invalidation |
|
| Account | Email change, password change, session invalidation |
|
||||||
| Notifications | Push notification subscription, journal prep push toggle |
|
| Profile | Per-user profile fields |
|
||||||
| Profile | About you, response preferences, interests, work schedule, locations + temperature unit, journal prep schedule, learned observations |
|
| Integrations | SearXNG status |
|
||||||
| Integrations | CalDAV configuration, SearXNG status |
|
| Data | Personal export; backup / restore (admin) |
|
||||||
| Data | Personal export, backup/restore (admin) |
|
| API Keys | Create/revoke `fmcp_` keys for the MCP endpoint |
|
||||||
| API Keys | Create/revoke API keys, Fable MCP download and install |
|
| Config (admin) | Base URL, SMTP, DB-maintenance schedule |
|
||||||
| Config (admin) | Base URL, SMTP, OIDC settings |
|
|
||||||
| Users (admin) | User list, invite links, registration toggle |
|
| Users (admin) | User list, invite links, registration toggle |
|
||||||
| Logs (admin) | Error, audit, and usage logs with search |
|
| Logs (admin) | Error, audit, and usage logs with search |
|
||||||
| Groups (admin) | Create/manage groups and membership |
|
| Groups (admin) | Create/manage groups and membership |
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
- Email integration (read/send via IMAP/SMTP tools in chat)
|
|
||||||
- Session invalidation on user deletion
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `g` + `h` | Go to Home |
|
| `g` + `h` | Home (dashboard) |
|
||||||
| `g` + `n` | Go to Notes |
|
| `g` + `n` | Notes |
|
||||||
| `g` + `t` | Go to Tasks |
|
| `g` + `t` | Knowledge (tasks) |
|
||||||
| `g` + `p` | Go to Projects |
|
| `g` + `p` | Projects |
|
||||||
| `g` + `c` | Go to Chat |
|
| `g` + `r` | Rulebooks |
|
||||||
| `g` (bare) | Go to Graph |
|
| `g` (bare) | Graph |
|
||||||
|
| `g` + `x` | Trash |
|
||||||
| `n` | New note |
|
| `n` | New note |
|
||||||
| `t` | New task |
|
| `t` | New task |
|
||||||
| `c` | Focus chat input |
|
| `/` | Focus search |
|
||||||
| `e` | Edit current item |
|
|
||||||
| `/` | Search |
|
|
||||||
| `?` | Show shortcuts panel |
|
| `?` | Show shortcuts panel |
|
||||||
| `j` / `k` | Navigate list items |
|
| `j` / `k` | Navigate list items |
|
||||||
| `Enter` | Open selected item |
|
| `Enter` | Open selected item |
|
||||||
| `Escape` | Close panel / blur / go home (progressive) |
|
| `e` | Edit current item |
|
||||||
|
| `Esc` | Close panel / blur / go home (progressive) |
|
||||||
| `Ctrl+S` | Save in editor |
|
| `Ctrl+S` | Save in editor |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,594 +0,0 @@
|
|||||||
# Streaming TTS Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Start playing TTS audio during LLM generation by splitting responses into sentences and synthesizing each sentence as it completes, rather than waiting for the full response.
|
|
||||||
|
|
||||||
**Architecture:** A new `useStreamingTts` composable watches `streamingContent` for sentence boundaries, fires per-sentence `synthesiseSpeech` requests concurrently, and plays audio in strict insertion order using `useVoiceAudio`. ChatView, BriefingView, and WorkspaceView all use this composable, replacing their current post-stream speak logic.
|
|
||||||
|
|
||||||
**Tech Stack:** Vue 3 Composition API, TypeScript, `useVoiceAudio` (existing), `synthesiseSpeech` from `api/client.ts` (existing), no backend changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | File | Responsibility |
|
|
||||||
|--------|------|----------------|
|
|
||||||
| **Create** | `frontend/src/composables/useStreamingTts.ts` | All streaming TTS logic: sentence splitting, TTS queuing, ordered playback |
|
|
||||||
| **Modify** | `frontend/src/views/ChatView.vue` | Replace `speakLastAssistantMessage` + old watch with `useStreamingTts` |
|
|
||||||
| **Modify** | `frontend/src/views/BriefingView.vue` | Replace `speakText` + `listenToLatest` + old watch with `useStreamingTts` |
|
|
||||||
| **Modify** | `frontend/src/views/WorkspaceView.vue` | Add listen mode toggle button + `useStreamingTts` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Create `useStreamingTts` composable
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/src/composables/useStreamingTts.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the composable**
|
|
||||||
|
|
||||||
Create `frontend/src/composables/useStreamingTts.ts` with the full implementation:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { ref, watch, computed } from 'vue'
|
|
||||||
import type { Ref, ComputedRef } from 'vue'
|
|
||||||
import { synthesiseSpeech } from '@/api/client'
|
|
||||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
|
||||||
|
|
||||||
/** Minimum stripped character count to bother synthesizing. */
|
|
||||||
const MIN_CHARS = 3
|
|
||||||
|
|
||||||
/** Matches sentence-terminal punctuation followed by whitespace or end-of-string. */
|
|
||||||
const SENTENCE_BOUNDARY = /[.!?]+(?=\s|$)/
|
|
||||||
|
|
||||||
function stripMarkdown(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/```[\s\S]*?```/g, '')
|
|
||||||
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
|
|
||||||
.replace(/#{1,6}\s+/g, '')
|
|
||||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
||||||
.replace(/\*([^*]+)\*/g, '$1')
|
|
||||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
||||||
.replace(/^\s*[-*+]\s+/gm, '')
|
|
||||||
.replace(/\n{2,}/g, ' ')
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract completed sentences from `text` using SENTENCE_BOUNDARY.
|
|
||||||
* Returns the sentences found and the unconsumed remainder.
|
|
||||||
*/
|
|
||||||
function extractSentences(text: string): { sentences: string[]; remainder: string } {
|
|
||||||
const sentences: string[] = []
|
|
||||||
let remaining = text
|
|
||||||
let match: RegExpExecArray | null
|
|
||||||
|
|
||||||
while ((match = SENTENCE_BOUNDARY.exec(remaining)) !== null) {
|
|
||||||
const boundary = match.index + match[0].length
|
|
||||||
const sentence = remaining.slice(0, boundary).trim()
|
|
||||||
if (sentence) sentences.push(sentence)
|
|
||||||
remaining = remaining.slice(boundary)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sentences, remainder: remaining }
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseStreamingTtsOptions {
|
|
||||||
streamingContent: Ref<string> | ComputedRef<string>
|
|
||||||
streaming: Ref<boolean> | ComputedRef<boolean>
|
|
||||||
enabled: Ref<boolean> | ComputedRef<boolean>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseStreamingTtsReturn {
|
|
||||||
/** True while any synthesis request is in-flight or audio is playing. */
|
|
||||||
speaking: ComputedRef<boolean>
|
|
||||||
/** Cancel all in-flight synthesis/playback and clear the queue. */
|
|
||||||
stop: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStreamingTts(options: UseStreamingTtsOptions): UseStreamingTtsReturn {
|
|
||||||
const { streamingContent, streaming, enabled } = options
|
|
||||||
const audio = useVoiceAudio()
|
|
||||||
|
|
||||||
let sentenceBuffer = ''
|
|
||||||
let lastSeenLength = 0
|
|
||||||
let abortId = 0
|
|
||||||
let playQueue: Promise<void> = Promise.resolve()
|
|
||||||
const pendingCount = ref(0)
|
|
||||||
|
|
||||||
const speaking = computed(() => pendingCount.value > 0 || audio.playing.value)
|
|
||||||
|
|
||||||
function stop(): void {
|
|
||||||
abortId++
|
|
||||||
sentenceBuffer = ''
|
|
||||||
lastSeenLength = 0
|
|
||||||
playQueue = Promise.resolve()
|
|
||||||
audio.stop()
|
|
||||||
pendingCount.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueueSentence(sentence: string, myAbortId: number): Promise<void> {
|
|
||||||
const stripped = stripMarkdown(sentence)
|
|
||||||
if (stripped.length < MIN_CHARS) return
|
|
||||||
|
|
||||||
pendingCount.value++
|
|
||||||
let blob: Blob | null = null
|
|
||||||
try {
|
|
||||||
blob = await synthesiseSpeech(stripped)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[StreamingTTS] Synthesis failed, retrying sentence', { sentence: stripped, error: e })
|
|
||||||
try {
|
|
||||||
blob = await synthesiseSpeech(stripped)
|
|
||||||
} catch (e2) {
|
|
||||||
console.warn('[StreamingTTS] Retry also failed, skipping sentence', { sentence: stripped, error: e2 })
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pendingCount.value--
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!blob) return
|
|
||||||
|
|
||||||
// Capture blob for the closure — TS can't narrow after async gap
|
|
||||||
const resolvedBlob = blob
|
|
||||||
playQueue = playQueue.then(async () => {
|
|
||||||
if (abortId !== myAbortId) return
|
|
||||||
await audio.play(resolvedBlob)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchBuffer(flush: boolean): void {
|
|
||||||
if (!enabled.value) return
|
|
||||||
const myAbortId = abortId
|
|
||||||
const { sentences, remainder } = extractSentences(sentenceBuffer)
|
|
||||||
sentenceBuffer = flush ? '' : remainder
|
|
||||||
for (const sentence of sentences) {
|
|
||||||
enqueueSentence(sentence, myAbortId)
|
|
||||||
}
|
|
||||||
if (flush && remainder.trim().length >= MIN_CHARS) {
|
|
||||||
enqueueSentence(remainder.trim(), myAbortId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch accumulating content — extract new characters since last check
|
|
||||||
watch(streamingContent, (newContent) => {
|
|
||||||
if (!enabled.value) return
|
|
||||||
const delta = newContent.slice(lastSeenLength)
|
|
||||||
lastSeenLength = newContent.length
|
|
||||||
sentenceBuffer += delta
|
|
||||||
dispatchBuffer(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Watch streaming flag — stop on new message start, flush on end
|
|
||||||
watch(streaming, (isStreaming) => {
|
|
||||||
if (!enabled.value) return
|
|
||||||
if (isStreaming) {
|
|
||||||
// New message starting — cancel previous response's audio
|
|
||||||
stop()
|
|
||||||
} else {
|
|
||||||
// Stream ended — flush any remaining fragment
|
|
||||||
dispatchBuffer(true)
|
|
||||||
lastSeenLength = 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return { speaking, stop }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: TypeScript check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
|
||||||
npx vue-tsc --noEmit 2>&1 | head -40
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors mentioning `useStreamingTts.ts`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/composables/useStreamingTts.ts
|
|
||||||
git commit -m "feat(tts): add useStreamingTts composable for sentence-level streaming"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Update ChatView
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/views/ChatView.vue`
|
|
||||||
|
|
||||||
Current TTS code to remove (lines ~35–66):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// REMOVE these:
|
|
||||||
const synthesising = ref(false);
|
|
||||||
|
|
||||||
async function speakLastAssistantMessage() { ... } // entire function
|
|
||||||
|
|
||||||
watch(() => store.streaming, async (streaming) => {
|
|
||||||
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
|
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
|
||||||
await speakLastAssistantMessage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Also remove the `synthesiseSpeech` import from `@/api/client` (it is no longer called directly in this file).
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add import and replace TTS logic**
|
|
||||||
|
|
||||||
In `frontend/src/views/ChatView.vue`:
|
|
||||||
|
|
||||||
1. Add to imports at the top of `<script setup>`:
|
|
||||||
```typescript
|
|
||||||
import { useStreamingTts } from "@/composables/useStreamingTts";
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Remove `synthesiseSpeech` from the `@/api/client` import line (keep other imports like `apiGet`, `transcribeAudio`).
|
|
||||||
|
|
||||||
3. Remove `const synthesising = ref(false);` (line ~35).
|
|
||||||
|
|
||||||
4. Remove the entire `speakLastAssistantMessage` function (lines ~38–59).
|
|
||||||
|
|
||||||
5. Remove the `watch(() => store.streaming, ...)` block that called `speakLastAssistantMessage` (lines ~61–66).
|
|
||||||
|
|
||||||
6. Add after `const listenMode = useListenMode();`:
|
|
||||||
```typescript
|
|
||||||
const tts = useStreamingTts({
|
|
||||||
streamingContent: computed(() => store.streamingContent),
|
|
||||||
streaming: computed(() => store.streaming),
|
|
||||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update template references**
|
|
||||||
|
|
||||||
In the ChatView template, replace every occurrence of `synthesising` with `tts.speaking.value`:
|
|
||||||
|
|
||||||
Find (line ~919):
|
|
||||||
```html
|
|
||||||
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': synthesising || audio.playing.value }"
|
|
||||||
```
|
|
||||||
Replace with:
|
|
||||||
```html
|
|
||||||
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': tts.speaking.value }"
|
|
||||||
```
|
|
||||||
|
|
||||||
Find (line ~920):
|
|
||||||
```html
|
|
||||||
@click="listenMode = !listenMode; if (listenMode) speakLastAssistantMessage()"
|
|
||||||
```
|
|
||||||
Replace with:
|
|
||||||
```html
|
|
||||||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
|
||||||
```
|
|
||||||
|
|
||||||
Find (line ~924):
|
|
||||||
```html
|
|
||||||
<svg v-if="!synthesising && !audio.playing.value" ...>
|
|
||||||
```
|
|
||||||
Replace with:
|
|
||||||
```html
|
|
||||||
<svg v-if="!tts.speaking.value" ...>
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: the `audio` variable (`useVoiceAudio()`) is still used for the volume slider and PTT stop — do NOT remove it.
|
|
||||||
|
|
||||||
- [ ] **Step 3: TypeScript check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
|
||||||
npx vue-tsc --noEmit 2>&1 | head -40
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/views/ChatView.vue
|
|
||||||
git commit -m "feat(tts): wire useStreamingTts into ChatView"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Update BriefingView
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/views/BriefingView.vue`
|
|
||||||
|
|
||||||
Current TTS code to remove:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// REMOVE:
|
|
||||||
const synthesising = ref(false)
|
|
||||||
|
|
||||||
async function speakText(text: string) { ... } // entire function
|
|
||||||
async function listenToLatest() { ... } // entire function
|
|
||||||
|
|
||||||
// REMOVE this watch block (the TTS one — keep the other streaming watch):
|
|
||||||
watch(() => chatStore.streaming, async (streaming) => {
|
|
||||||
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
|
|
||||||
await new Promise((r) => setTimeout(r, 200))
|
|
||||||
await listenToLatest()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: BriefingView has **two** `watch(() => chatStore.streaming, ...)` blocks. Keep the first one (lines ~152–156, which refreshes messages). Remove only the TTS one (lines ~327–332).
|
|
||||||
|
|
||||||
Also remove the `synthesiseSpeech` import from `@/api/client`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add import and replace TTS logic**
|
|
||||||
|
|
||||||
In `frontend/src/views/BriefingView.vue`:
|
|
||||||
|
|
||||||
1. Add to imports:
|
|
||||||
```typescript
|
|
||||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Remove `synthesiseSpeech` from the `@/api/client` import line.
|
|
||||||
|
|
||||||
3. Remove `const synthesising = ref(false)`.
|
|
||||||
|
|
||||||
4. Remove the entire `speakText` function.
|
|
||||||
|
|
||||||
5. Remove the entire `listenToLatest` function.
|
|
||||||
|
|
||||||
6. Remove the TTS `watch(() => chatStore.streaming, ...)` block (the one that calls `listenToLatest`).
|
|
||||||
|
|
||||||
7. Add after `const listenMode = useListenMode()`:
|
|
||||||
```typescript
|
|
||||||
const tts = useStreamingTts({
|
|
||||||
streamingContent: computed(() => chatStore.streamingContent),
|
|
||||||
streaming: computed(() => chatStore.streaming),
|
|
||||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update template references**
|
|
||||||
|
|
||||||
Find the listen toggle button in the template. Replace `synthesising` references:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Before -->
|
|
||||||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }"
|
|
||||||
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
|
|
||||||
|
|
||||||
<!-- After -->
|
|
||||||
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': tts.speaking.value }"
|
|
||||||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
|
||||||
```
|
|
||||||
|
|
||||||
Find the stop button:
|
|
||||||
```html
|
|
||||||
<!-- Before -->
|
|
||||||
v-if="voiceTtsEnabled && (synthesising || audio.playing.value)"
|
|
||||||
@click="audio.stop(); synthesising = false"
|
|
||||||
|
|
||||||
<!-- After -->
|
|
||||||
v-if="voiceTtsEnabled && tts.speaking.value"
|
|
||||||
@click="tts.stop()"
|
|
||||||
```
|
|
||||||
|
|
||||||
Find the spinner SVG condition:
|
|
||||||
```html
|
|
||||||
<!-- Before -->
|
|
||||||
<svg v-if="!synthesising && !audio.playing.value" ...>
|
|
||||||
|
|
||||||
<!-- After -->
|
|
||||||
<svg v-if="!tts.speaking.value" ...>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: TypeScript check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
|
||||||
npx vue-tsc --noEmit 2>&1 | head -40
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/views/BriefingView.vue
|
|
||||||
git commit -m "feat(tts): wire useStreamingTts into BriefingView"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Add streaming TTS to WorkspaceView
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/views/WorkspaceView.vue`
|
|
||||||
|
|
||||||
WorkspaceView has no TTS today. We add: listen mode toggle, `useStreamingTts`, and the listen button in the chat input toolbar.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add imports and composable**
|
|
||||||
|
|
||||||
In `frontend/src/views/WorkspaceView.vue`, add to the import block at the top of `<script setup>`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useListenMode } from '@/composables/useListenMode'
|
|
||||||
import { useStreamingTts } from '@/composables/useStreamingTts'
|
|
||||||
import { useVoiceAudio } from '@/composables/useVoiceAudio'
|
|
||||||
```
|
|
||||||
|
|
||||||
After the existing store setup code (after `const settingsStore = useSettingsStore()`), add:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const listenMode = useListenMode()
|
|
||||||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
|
|
||||||
const audio = useVoiceAudio()
|
|
||||||
const tts = useStreamingTts({
|
|
||||||
streamingContent: computed(() => chatStore.streamingContent),
|
|
||||||
streaming: computed(() => chatStore.streaming),
|
|
||||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add listen mode button to template**
|
|
||||||
|
|
||||||
In the `<div class="chat-input-area">` section (around line 365), add the listen button before the abort/send button:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="chat-input-area">
|
|
||||||
<textarea
|
|
||||||
ref="inputEl"
|
|
||||||
v-model="messageInput"
|
|
||||||
class="chat-input"
|
|
||||||
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent… (Enter to send)'"
|
|
||||||
rows="1"
|
|
||||||
@keydown="onInputKeydown"
|
|
||||||
@input="autoResize"
|
|
||||||
></textarea>
|
|
||||||
<!-- Listen mode toggle (TTS) -->
|
|
||||||
<button
|
|
||||||
v-if="voiceTtsEnabled"
|
|
||||||
class="btn-listen-ws"
|
|
||||||
:class="{ 'btn-listen-ws--active': listenMode, 'btn-listen-ws--busy': tts.speaking.value }"
|
|
||||||
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
|
|
||||||
aria-label="Toggle listen mode"
|
|
||||||
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
|
|
||||||
>
|
|
||||||
<svg v-if="!tts.speaking.value" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
|
||||||
</svg>
|
|
||||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="chatStore.streaming"
|
|
||||||
class="btn-abort"
|
|
||||||
title="Stop generation"
|
|
||||||
@click="chatStore.cancelGeneration()"
|
|
||||||
>
|
|
||||||
■ Stop
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="btn-send"
|
|
||||||
:disabled="!messageInput.trim()"
|
|
||||||
@click="sendMessage"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add CSS for the listen button**
|
|
||||||
|
|
||||||
In the `<style>` block, add:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.btn-listen-ws {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.btn-listen-ws:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.btn-listen-ws--active {
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
|
||||||
}
|
|
||||||
.btn-listen-ws--busy {
|
|
||||||
color: var(--color-primary);
|
|
||||||
animation: pulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: TypeScript check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
|
||||||
npx vue-tsc --noEmit 2>&1 | head -40
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/views/WorkspaceView.vue
|
|
||||||
git commit -m "feat(tts): add streaming TTS listen mode to WorkspaceView"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Final integration check and push
|
|
||||||
|
|
||||||
- [ ] **Step 1: Full TypeScript check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
|
|
||||||
npx vue-tsc --noEmit 2>&1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: zero errors.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify no dead imports remain**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "synthesiseSpeech\|speakLastAssistantMessage\|speakText\|listenToLatest\|synthesising" \
|
|
||||||
frontend/src/views/ChatView.vue \
|
|
||||||
frontend/src/views/BriefingView.vue \
|
|
||||||
frontend/src/views/WorkspaceView.vue
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no matches (all replaced).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Manual smoke test**
|
|
||||||
|
|
||||||
1. Enable voice in Admin → Config
|
|
||||||
2. Open Chat, enable listen mode (speaker icon)
|
|
||||||
3. Send a message and watch: audio should begin playing the first sentence while the LLM is still streaming the response
|
|
||||||
4. Send another message mid-playback — previous audio should stop immediately
|
|
||||||
5. Toggle listen mode off mid-response — audio stops, `tts.stop()` called
|
|
||||||
6. Repeat in `/briefing` and `/workspace/:id`
|
|
||||||
|
|
||||||
- [ ] **Step 4: Push**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git push origin dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
**Spec coverage check:**
|
|
||||||
- ✅ Starts playing during generation (sentence-level queue, fires on each boundary)
|
|
||||||
- ✅ Automatic when listen mode on (enabled computed = listenMode && voiceTtsEnabled)
|
|
||||||
- ✅ ChatView updated
|
|
||||||
- ✅ BriefingView updated
|
|
||||||
- ✅ WorkspaceView added
|
|
||||||
- ✅ One retry before skipping on failure
|
|
||||||
- ✅ Failures logged via `console.warn` with sentence text and error
|
|
||||||
- ✅ `stop()` on new message start (watch streaming → true)
|
|
||||||
- ✅ Flush remaining buffer on stream end (watch streaming → false)
|
|
||||||
- ✅ Fragments < 3 chars skipped
|
|
||||||
- ✅ `abortId` prevents stale playback after stop
|
|
||||||
|
|
||||||
**Type consistency:**
|
|
||||||
- `tts.speaking` is `ComputedRef<boolean>` — accessed as `tts.speaking.value` in templates ✅
|
|
||||||
- `tts.stop()` called consistently across all three views ✅
|
|
||||||
- `useStreamingTts` options match usage in all three call sites ✅
|
|
||||||
- `audio` variable kept in ChatView (used by volume slider) — not removed ✅
|
|
||||||
@@ -1,695 +0,0 @@
|
|||||||
# Article Reading Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add a `read_article` tool so the LLM can fetch any URL, fix the history builder so tool context survives follow-up turns, redesign the Discuss button to inject article content as a persisted tool exchange, and remove the RSS content character cap.
|
|
||||||
|
|
||||||
**Architecture:** Four independent changes executed in dependency order: (1) content cap removal, (2) `read_article` tool, (3) history builder fix (prerequisite for everything persisting across follow-ups), (4) Discuss endpoint + frontend. Each task is independently committable.
|
|
||||||
|
|
||||||
**Tech Stack:** Python/Quart, SQLAlchemy async, trafilatura (already installed), httpx (already installed), Vue 3 + TypeScript frontend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File map
|
|
||||||
|
|
||||||
| Action | Path | Responsibility |
|
|
||||||
|---|---|---|
|
|
||||||
| Modify | `src/fabledassistant/services/rss.py` | Remove `CONTENT_MAX_CHARS` truncation |
|
|
||||||
| Modify | `src/fabledassistant/services/tools.py` | Add `_URL_TOOLS` list, add `read_article` to `get_tools_for_user`, add handler in `execute_tool` |
|
|
||||||
| Modify | `src/fabledassistant/routes/chat.py` | Fix history builder to replay tool_calls |
|
|
||||||
| Modify | `src/fabledassistant/services/chat.py` | Add `tool_calls` parameter to `add_message` |
|
|
||||||
| Modify | `src/fabledassistant/routes/briefing.py` | Add `POST /api/briefing/articles/<item_id>/discuss` endpoint |
|
|
||||||
| Modify | `frontend/src/views/BriefingView.vue` | Replace `discussArticle()` to call new endpoint |
|
|
||||||
| Modify | `tests/test_rss_service.py` | Update truncation test, add no-truncation test |
|
|
||||||
| Create | `tests/test_article_reading.py` | Tests for `read_article` tool and history builder |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Remove RSS content cap
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/rss.py:17-18,83,213`
|
|
||||||
- Modify: `tests/test_rss_service.py:19-26`
|
|
||||||
|
|
||||||
The `CONTENT_MAX_CHARS = 50_000` constant and all uses of `[:CONTENT_MAX_CHARS]` are removed.
|
|
||||||
Trafilatura extracts only article body text, so content is naturally bounded.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update the truncation test to assert no truncation**
|
|
||||||
|
|
||||||
In `tests/test_rss_service.py`, replace the existing `test_extract_item_truncates_content` test:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_extract_item_does_not_truncate_content():
|
|
||||||
"""extract_item() should store content without truncation."""
|
|
||||||
from fabledassistant.services.rss import extract_item
|
|
||||||
long_text = "x" * 100_000
|
|
||||||
entry = MagicMock()
|
|
||||||
entry.get = lambda k, d="": {"summary": long_text, "title": "", "link": "", "id": "g"}.get(k, d)
|
|
||||||
entry.content = []
|
|
||||||
entry.published_parsed = None
|
|
||||||
item = extract_item(entry)
|
|
||||||
assert len(item["content"]) == 100_000
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to confirm it fails**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
|
||||||
make test ARGS="tests/test_rss_service.py::test_extract_item_does_not_truncate_content -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: FAIL (current code truncates to 50_000).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Remove CONTENT_MAX_CHARS from rss.py**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/rss.py`:
|
|
||||||
|
|
||||||
Remove lines 17–18:
|
|
||||||
```python
|
|
||||||
# Safety cap on stored content — effectively unlimited for typical articles
|
|
||||||
CONTENT_MAX_CHARS = 50_000
|
|
||||||
```
|
|
||||||
|
|
||||||
Change line 83 from:
|
|
||||||
```python
|
|
||||||
content = _html_to_text(content)[:CONTENT_MAX_CHARS]
|
|
||||||
```
|
|
||||||
to:
|
|
||||||
```python
|
|
||||||
content = _html_to_text(content)
|
|
||||||
```
|
|
||||||
|
|
||||||
Change line 213 from:
|
|
||||||
```python
|
|
||||||
item.content = full_text[:CONTENT_MAX_CHARS]
|
|
||||||
```
|
|
||||||
to:
|
|
||||||
```python
|
|
||||||
item.content = full_text
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run all rss tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test ARGS="tests/test_rss_service.py -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all pass. The `test_extract_item_truncates_content` test name no longer exists (replaced in Step 1).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/rss.py tests/test_rss_service.py
|
|
||||||
git commit -m "feat(rss): remove article content character cap"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Add `read_article` tool
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/tools.py`
|
|
||||||
- Create: `tests/test_article_reading.py`
|
|
||||||
|
|
||||||
The tool uses `_fetch_full_article` from `rss.py` (lazy import inside `execute_tool` to avoid circular dependencies). Added unconditionally to all users via a new `_URL_TOOLS` list.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Create `tests/test_article_reading.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import json
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_read_article_success():
|
|
||||||
"""read_article tool returns article content on success."""
|
|
||||||
from fabledassistant.services.tools import execute_tool
|
|
||||||
with patch(
|
|
||||||
"fabledassistant.services.rss._fetch_full_article",
|
|
||||||
new=AsyncMock(return_value="Article text here."),
|
|
||||||
):
|
|
||||||
result = await execute_tool(
|
|
||||||
user_id=1,
|
|
||||||
tool_name="read_article",
|
|
||||||
arguments={"url": "https://example.com/article"},
|
|
||||||
)
|
|
||||||
assert result["success"] is True
|
|
||||||
assert result["type"] == "article_content"
|
|
||||||
assert result["url"] == "https://example.com/article"
|
|
||||||
assert result["content"] == "Article text here."
|
|
||||||
assert result["truncated"] is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_read_article_fetch_failure():
|
|
||||||
"""read_article tool returns success=False when fetch returns None."""
|
|
||||||
from fabledassistant.services.tools import execute_tool
|
|
||||||
with patch(
|
|
||||||
"fabledassistant.services.rss._fetch_full_article",
|
|
||||||
new=AsyncMock(return_value=None),
|
|
||||||
):
|
|
||||||
result = await execute_tool(
|
|
||||||
user_id=1,
|
|
||||||
tool_name="read_article",
|
|
||||||
arguments={"url": "https://example.com/bad"},
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
|
||||||
assert "Could not fetch" in result["error"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_read_article_truncates_at_40k():
|
|
||||||
"""read_article tool truncates content at 40_000 chars and sets truncated=True."""
|
|
||||||
from fabledassistant.services.tools import execute_tool
|
|
||||||
long_content = "x" * 50_000
|
|
||||||
with patch(
|
|
||||||
"fabledassistant.services.rss._fetch_full_article",
|
|
||||||
new=AsyncMock(return_value=long_content),
|
|
||||||
):
|
|
||||||
result = await execute_tool(
|
|
||||||
user_id=1,
|
|
||||||
tool_name="read_article",
|
|
||||||
arguments={"url": "https://example.com/long"},
|
|
||||||
)
|
|
||||||
assert result["success"] is True
|
|
||||||
assert len(result["content"]) == 40_000
|
|
||||||
assert result["truncated"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_read_article_empty_url():
|
|
||||||
"""read_article tool returns success=False when url is empty."""
|
|
||||||
from fabledassistant.services.tools import execute_tool
|
|
||||||
result = await execute_tool(
|
|
||||||
user_id=1,
|
|
||||||
tool_name="read_article",
|
|
||||||
arguments={"url": ""},
|
|
||||||
)
|
|
||||||
assert result["success"] is False
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to confirm they fail**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test ARGS="tests/test_article_reading.py -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all 4 fail with "read_article not handled" or AttributeError.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `_URL_TOOLS` list and register it in `get_tools_for_user`**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/tools.py`, add the `_URL_TOOLS` list immediately after the `_SEARCH_TOOLS` block (around line 836):
|
|
||||||
|
|
||||||
```python
|
|
||||||
_URL_TOOLS = [
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "read_article",
|
|
||||||
"description": (
|
|
||||||
"Fetch and read the full text of a web page or article from a URL. "
|
|
||||||
"Use when the user shares a URL and wants you to read it, or to get "
|
|
||||||
"the full content of a linked page. "
|
|
||||||
"Do NOT use search_web for URLs — use this tool instead."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"url": {"type": "string", "description": "The URL to fetch and read"}
|
|
||||||
},
|
|
||||||
"required": ["url"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
In `get_tools_for_user` (around line 1034), add `_URL_TOOLS` unconditionally after `_CORE_TOOLS`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def get_tools_for_user(user_id: int) -> list[dict]:
|
|
||||||
"""Build the tool list for a user based on their configured integrations."""
|
|
||||||
tools = list(_CORE_TOOLS)
|
|
||||||
tools.extend(_URL_TOOLS)
|
|
||||||
tools.extend(_RAG_TOOLS)
|
|
||||||
tools.extend(_ENTITY_TOOLS)
|
|
||||||
if await is_caldav_configured(user_id):
|
|
||||||
tools.extend(_CALDAV_TOOLS)
|
|
||||||
if Config.searxng_enabled():
|
|
||||||
tools.extend(_SEARCH_TOOLS)
|
|
||||||
tools.extend(_RESEARCH_TOOLS)
|
|
||||||
tools.extend(_IMAGE_TOOLS)
|
|
||||||
logger.debug("User %d: %d tools available", user_id, len(tools))
|
|
||||||
return tools
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add `read_article` handler in `execute_tool`**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/tools.py`, in the `execute_tool` function, find the `elif tool_name == "search_web":` block (around line 1771). Add the new handler immediately before it:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif tool_name == "read_article":
|
|
||||||
from fabledassistant.services.rss import _fetch_full_article
|
|
||||||
url = arguments.get("url", "").strip()
|
|
||||||
if not url:
|
|
||||||
return {"success": False, "error": "No URL provided"}
|
|
||||||
content = await _fetch_full_article(url)
|
|
||||||
if not content:
|
|
||||||
return {"success": False, "error": f"Could not fetch article content from {url}"}
|
|
||||||
_TOOL_CONTENT_CAP = 40_000
|
|
||||||
truncated = len(content) > _TOOL_CONTENT_CAP
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"type": "article_content",
|
|
||||||
"url": url,
|
|
||||||
"content": content[:_TOOL_CONTENT_CAP],
|
|
||||||
"truncated": truncated,
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run the tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test ARGS="tests/test_article_reading.py -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all 4 pass.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run full test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all pass.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/tools.py tests/test_article_reading.py
|
|
||||||
git commit -m "feat(tools): add read_article tool using trafilatura extraction"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Fix history builder
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/routes/chat.py:162-166`
|
|
||||||
- Modify: `tests/test_article_reading.py` (add history builder tests)
|
|
||||||
|
|
||||||
The loop that builds `history` for `run_generation` currently drops `tool_calls`. This fix replays the full tool exchange so the LLM sees prior tool results on follow-up turns.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add history builder tests**
|
|
||||||
|
|
||||||
Append to `tests/test_article_reading.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_history_builder_plain_messages():
|
|
||||||
"""Messages without tool_calls are added as {role, content} unchanged."""
|
|
||||||
import json
|
|
||||||
messages = [
|
|
||||||
type("M", (), {"role": "system", "content": "sys", "tool_calls": None})(),
|
|
||||||
type("M", (), {"role": "user", "content": "hello", "tool_calls": None})(),
|
|
||||||
type("M", (), {"role": "assistant", "content": "hi", "tool_calls": None})(),
|
|
||||||
]
|
|
||||||
history = _build_history(messages)
|
|
||||||
assert history == [
|
|
||||||
{"role": "user", "content": "hello"},
|
|
||||||
{"role": "assistant", "content": "hi"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_history_builder_with_tool_calls():
|
|
||||||
"""Messages with tool_calls emit an assistant entry + tool result entries."""
|
|
||||||
import json
|
|
||||||
tool_calls_data = [
|
|
||||||
{
|
|
||||||
"function": "read_article",
|
|
||||||
"arguments": {"url": "https://example.com"},
|
|
||||||
"result": {"success": True, "content": "Article text"},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
messages = [
|
|
||||||
type("M", (), {"role": "user", "content": "read this", "tool_calls": None})(),
|
|
||||||
type("M", (), {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": tool_calls_data,
|
|
||||||
})(),
|
|
||||||
type("M", (), {"role": "user", "content": "follow up", "tool_calls": None})(),
|
|
||||||
]
|
|
||||||
history = _build_history(messages)
|
|
||||||
assert history[0] == {"role": "user", "content": "read this"}
|
|
||||||
assert history[1]["role"] == "assistant"
|
|
||||||
assert history[1]["tool_calls"] == [
|
|
||||||
{"function": {"name": "read_article", "arguments": {"url": "https://example.com"}}}
|
|
||||||
]
|
|
||||||
assert history[2] == {"role": "tool", "content": json.dumps({"success": True, "content": "Article text"})}
|
|
||||||
assert history[3] == {"role": "user", "content": "follow up"}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_history(messages):
|
|
||||||
"""Inline copy of the fixed history builder for testing."""
|
|
||||||
import json
|
|
||||||
history = []
|
|
||||||
for msg in messages:
|
|
||||||
if msg.role == "system":
|
|
||||||
continue
|
|
||||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
|
||||||
if msg.tool_calls:
|
|
||||||
msg_dict["tool_calls"] = [
|
|
||||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
|
||||||
for tc in msg.tool_calls
|
|
||||||
]
|
|
||||||
history.append(msg_dict)
|
|
||||||
for tc in msg.tool_calls:
|
|
||||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
|
||||||
else:
|
|
||||||
history.append(msg_dict)
|
|
||||||
return history
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the tests to confirm they pass**
|
|
||||||
|
|
||||||
(These tests use `_build_history` defined inline — they test the logic directly, not the route. They should pass immediately.)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test ARGS="tests/test_article_reading.py::test_history_builder_plain_messages tests/test_article_reading.py::test_history_builder_with_tool_calls -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: both pass.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Apply the fix to `chat.py`**
|
|
||||||
|
|
||||||
In `src/fabledassistant/routes/chat.py`, replace lines 162–166:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Build history from existing messages (excluding system and the placeholder)
|
|
||||||
history = []
|
|
||||||
for msg in conv.messages:
|
|
||||||
if msg.role != "system":
|
|
||||||
history.append({"role": msg.role, "content": msg.content})
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Build history from existing messages (excluding system and the placeholder).
|
|
||||||
# Tool calls from prior turns are replayed as assistant tool_call + tool result
|
|
||||||
# messages so the LLM retains tool context on follow-up turns.
|
|
||||||
history = []
|
|
||||||
for msg in conv.messages:
|
|
||||||
if msg.role == "system":
|
|
||||||
continue
|
|
||||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
|
||||||
if msg.tool_calls:
|
|
||||||
msg_dict["tool_calls"] = [
|
|
||||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
|
||||||
for tc in msg.tool_calls
|
|
||||||
]
|
|
||||||
history.append(msg_dict)
|
|
||||||
for tc in msg.tool_calls:
|
|
||||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
|
||||||
else:
|
|
||||||
history.append(msg_dict)
|
|
||||||
```
|
|
||||||
|
|
||||||
`json` is already imported at the top of `chat.py`.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run full test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all pass.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/routes/chat.py tests/test_article_reading.py
|
|
||||||
git commit -m "fix(chat): replay tool_calls in history so tool context survives follow-up turns"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Extend `add_message` to accept `tool_calls`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/chat.py:183-207`
|
|
||||||
|
|
||||||
The Discuss endpoint (Task 5) needs to store a synthetic assistant message with `tool_calls`. The existing `add_message` doesn't support this parameter.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update `add_message` signature and body**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/chat.py`, replace the `add_message` function (lines 183–207):
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def add_message(
|
|
||||||
conversation_id: int,
|
|
||||||
role: str,
|
|
||||||
content: str,
|
|
||||||
context_note_id: int | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
tool_calls: list | None = None,
|
|
||||||
) -> Message:
|
|
||||||
async with async_session() as session:
|
|
||||||
kwargs: dict = dict(
|
|
||||||
conversation_id=conversation_id,
|
|
||||||
role=role,
|
|
||||||
content=content,
|
|
||||||
context_note_id=context_note_id,
|
|
||||||
)
|
|
||||||
if status is not None:
|
|
||||||
kwargs["status"] = status
|
|
||||||
if tool_calls is not None:
|
|
||||||
kwargs["tool_calls"] = tool_calls
|
|
||||||
msg = Message(**kwargs)
|
|
||||||
session.add(msg)
|
|
||||||
# Touch 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
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run full test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all pass (existing callers only use positional/keyword args that are unchanged).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/chat.py
|
|
||||||
git commit -m "feat(chat): add tool_calls parameter to add_message"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Add Discuss endpoint and update frontend
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/routes/briefing.py`
|
|
||||||
- Modify: `frontend/src/views/BriefingView.vue`
|
|
||||||
|
|
||||||
New route: `POST /api/briefing/articles/<item_id>/discuss`. Fetches stored article from DB, stores a synthetic `read_article` tool exchange plus the user message, then triggers generation. Frontend replaces the inline-content approach with a call to this endpoint.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the discuss endpoint to briefing.py**
|
|
||||||
|
|
||||||
At the top of `src/fabledassistant/routes/briefing.py`, add these imports (after the existing imports):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
|
||||||
from fabledassistant.services.chat import add_message, get_conversation
|
|
||||||
from fabledassistant.services.generation_buffer import GenerationState, create_buffer, get_buffer
|
|
||||||
from fabledassistant.services.generation_task import run_generation
|
|
||||||
from fabledassistant.services.settings import get_setting
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `get_setting` and `asyncio` are already imported. Add only what is missing.
|
|
||||||
|
|
||||||
Then add the new route at the end of `briefing.py` (before any final lines), after the `list_news` route:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
|
|
||||||
@_REQUIRE
|
|
||||||
async def discuss_article(item_id: int):
|
|
||||||
"""Pre-load a briefing article as a read_article tool exchange and trigger generation."""
|
|
||||||
uid = g.user.id
|
|
||||||
data = await request.get_json() or {}
|
|
||||||
conv_id = data.get("conv_id")
|
|
||||||
if not conv_id:
|
|
||||||
return jsonify({"error": "conv_id is required"}), 400
|
|
||||||
|
|
||||||
# Verify article belongs to this user (via feed ownership)
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(RssItem).join(RssFeed, RssItem.feed_id == RssFeed.id)
|
|
||||||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
|
||||||
)
|
|
||||||
item = result.scalar_one_or_none()
|
|
||||||
if item is None:
|
|
||||||
return jsonify({"error": "Article not found"}), 404
|
|
||||||
|
|
||||||
# Verify conversation belongs to this user
|
|
||||||
conv = await get_conversation(uid, conv_id)
|
|
||||||
if conv is None:
|
|
||||||
return jsonify({"error": "Conversation not found"}), 404
|
|
||||||
|
|
||||||
# Reject if generation already running
|
|
||||||
existing = get_buffer(conv_id)
|
|
||||||
if existing and existing.state == GenerationState.RUNNING:
|
|
||||||
return jsonify({"error": "Generation already in progress"}), 409
|
|
||||||
|
|
||||||
article_content = item.content or ""
|
|
||||||
|
|
||||||
# Store synthetic assistant message: read_article was already called with stored content
|
|
||||||
synthetic_tool_calls = [
|
|
||||||
{
|
|
||||||
"function": "read_article",
|
|
||||||
"arguments": {"url": item.url},
|
|
||||||
"result": {
|
|
||||||
"success": True,
|
|
||||||
"type": "article_content",
|
|
||||||
"url": item.url,
|
|
||||||
"content": article_content,
|
|
||||||
"truncated": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
|
||||||
|
|
||||||
# Store user message
|
|
||||||
await add_message(conv_id, "user", "Please summarize and discuss this article.")
|
|
||||||
|
|
||||||
# Reload conversation so history includes the two new messages
|
|
||||||
conv = await get_conversation(uid, conv_id)
|
|
||||||
|
|
||||||
# Build history (using the fixed builder from chat.py logic — duplicated here)
|
|
||||||
history = []
|
|
||||||
for msg in conv.messages:
|
|
||||||
if msg.role == "system":
|
|
||||||
continue
|
|
||||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
|
||||||
if msg.tool_calls:
|
|
||||||
msg_dict["tool_calls"] = [
|
|
||||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
|
||||||
for tc in msg.tool_calls
|
|
||||||
]
|
|
||||||
history.append(msg_dict)
|
|
||||||
for tc in msg.tool_calls:
|
|
||||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
|
||||||
else:
|
|
||||||
history.append(msg_dict)
|
|
||||||
|
|
||||||
model = await get_setting(uid, "default_model", "") or ""
|
|
||||||
from fabledassistant.config import Config as _Config
|
|
||||||
if not model:
|
|
||||||
model = _Config.OLLAMA_MODEL
|
|
||||||
|
|
||||||
# Create placeholder assistant message and generation buffer
|
|
||||||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
|
||||||
try:
|
|
||||||
buf = create_buffer(conv_id, assistant_msg.id)
|
|
||||||
except RuntimeError:
|
|
||||||
return jsonify({"error": "Generation already in progress"}), 409
|
|
||||||
|
|
||||||
asyncio.create_task(run_generation(
|
|
||||||
buf, history, model,
|
|
||||||
uid, conv_id, conv.title,
|
|
||||||
"Please summarize and discuss this article.",
|
|
||||||
think=True,
|
|
||||||
))
|
|
||||||
|
|
||||||
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run full test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all pass.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update `discussArticle` in BriefingView.vue**
|
|
||||||
|
|
||||||
In `frontend/src/views/BriefingView.vue`, replace the `discussArticle` function:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async function discussArticle(item: NewsItem) {
|
|
||||||
if (!todayConvId.value || chatStore.streaming) return
|
|
||||||
if (!isToday.value) selectedConvId.value = todayConvId.value
|
|
||||||
await nextTick(() => {
|
|
||||||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
await apiPost<{ assistant_message_id: number }>(
|
|
||||||
`/api/briefing/articles/${item.id}/discuss`,
|
|
||||||
{ conv_id: todayConvId.value },
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Reload conversation so the new messages appear (including the generating placeholder),
|
|
||||||
// then reconnect to the SSE stream using the existing reconnectIfGenerating helper.
|
|
||||||
await chatStore.fetchConversation(todayConvId.value)
|
|
||||||
await chatStore.reconnectIfGenerating(todayConvId.value)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`reconnectIfGenerating` is already exported from `useChatStore`. It finds the assistant message in `status="generating"` state and connects to the SSE stream automatically. No changes to `chat.ts` are needed.
|
|
||||||
|
|
||||||
- [ ] **Step 4: TypeScript check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
|
||||||
npm --prefix frontend run type-check
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/routes/briefing.py frontend/src/views/BriefingView.vue frontend/src/stores/chat.ts
|
|
||||||
git commit -m "feat(briefing): add discuss endpoint and update frontend to use persisted article context"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Final verification
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run full test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
|
||||||
make test
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: all tests pass.
|
|
||||||
|
|
||||||
- [ ] **Step 2: TypeScript check**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm --prefix frontend run type-check
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Push**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git push origin dev
|
|
||||||
```
|
|
||||||
@@ -1,479 +0,0 @@
|
|||||||
# Web Voice Overlay Polish — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it in `App.vue`, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection backed by a new `useSilenceDetector` composable.
|
|
||||||
|
|
||||||
**Architecture:** A new `useSilenceDetector` composable uses `AudioContext` + `AnalyserNode` to monitor amplitude from a live `MediaStream` and fires a callback after sustained silence. `VoiceOverlay` coordinates recording and silence detection, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds a Space bar handler that dispatches the existing `voice:ptt-toggle` custom event.
|
|
||||||
|
|
||||||
**Tech Stack:** Vue 3 Composition API, TypeScript, Web Audio API (`AudioContext`, `AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | Path |
|
|
||||||
|--------|------|
|
|
||||||
| Create | `frontend/src/composables/useSilenceDetector.ts` |
|
|
||||||
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
|
|
||||||
| Modify | `frontend/src/components/VoiceOverlay.vue` |
|
|
||||||
| Modify | `frontend/src/App.vue` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: `useSilenceDetector` composable
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/src/composables/useSilenceDetector.ts`
|
|
||||||
|
|
||||||
**Context:** The Web Audio API lets us pipe a `MediaStream` into an `AnalyserNode` and read frequency data as a byte array every 100 ms. RMS amplitude of that array gives a 0–1 loudness value; converting to dB lets us use the same `-40 dB` threshold as the Android app. The composable must be safe to call `stop()` on multiple times and must reset amplitude to 0 after stopping so the animated bars collapse.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the file with full implementation**
|
|
||||||
|
|
||||||
`frontend/src/composables/useSilenceDetector.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { ref, readonly } from 'vue'
|
|
||||||
|
|
||||||
export interface SilenceDetectorOptions {
|
|
||||||
thresholdDb?: number // default -40
|
|
||||||
silenceDurationMs?: number // default 1500
|
|
||||||
minRecordingMs?: number // default 500
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
|
||||||
const {
|
|
||||||
thresholdDb = -40,
|
|
||||||
silenceDurationMs = 1500,
|
|
||||||
minRecordingMs = 500,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const amplitude = ref(0)
|
|
||||||
let audioCtx: AudioContext | null = null
|
|
||||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
|
||||||
let silenceMs = 0
|
|
||||||
let startedAt = 0
|
|
||||||
|
|
||||||
function start(stream: MediaStream, onSilence: () => void): void {
|
|
||||||
stop()
|
|
||||||
audioCtx = new AudioContext()
|
|
||||||
const source = audioCtx.createMediaStreamSource(stream)
|
|
||||||
const analyser = audioCtx.createAnalyser()
|
|
||||||
analyser.fftSize = 256
|
|
||||||
source.connect(analyser)
|
|
||||||
|
|
||||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
|
||||||
silenceMs = 0
|
|
||||||
startedAt = Date.now()
|
|
||||||
|
|
||||||
intervalId = setInterval(() => {
|
|
||||||
analyser.getByteFrequencyData(data)
|
|
||||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
|
||||||
amplitude.value = rms
|
|
||||||
|
|
||||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
|
||||||
if (db < thresholdDb) {
|
|
||||||
silenceMs += 100
|
|
||||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
|
||||||
stop()
|
|
||||||
onSilence()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
silenceMs = 0
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop(): void {
|
|
||||||
if (intervalId !== null) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = null
|
|
||||||
}
|
|
||||||
if (audioCtx) {
|
|
||||||
audioCtx.close().catch(() => {})
|
|
||||||
audioCtx = null
|
|
||||||
}
|
|
||||||
amplitude.value = 0
|
|
||||||
silenceMs = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return { amplitude: readonly(amplitude), start, stop }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/fabledassistant/frontend
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/composables/useSilenceDetector.ts
|
|
||||||
git commit -m "feat: add useSilenceDetector composable with Web Audio API amplitude monitoring"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Expose `stream` ref from `useVoiceRecorder`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
|
|
||||||
|
|
||||||
**Context:** Currently `stream` is a plain `let` variable inside the closure. `VoiceOverlay` needs to pass the live `MediaStream` to `useSilenceDetector.start()` after recording begins. Exposing it as a readonly `Ref<MediaStream | null>` is the minimal change — no other callers are broken because they don't currently read `stream` from the return value.
|
|
||||||
|
|
||||||
The current file is at `frontend/src/composables/useVoiceRecorder.ts`. Read it before editing — the key lines to change are:
|
|
||||||
|
|
||||||
1. Top of function body: `let stream: MediaStream | null = null` → `const streamRef = ref<MediaStream | null>(null)`
|
|
||||||
2. In `startRecording()`: `stream = await navigator.mediaDevices.getUserMedia({ audio: true })` → `streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })`
|
|
||||||
3. In `startRecording()` catch block: `stream = null` if present — replace with `streamRef.value = null` (if the catch sets stream to null; if not, skip)
|
|
||||||
4. In `mediaRecorder.onstop`: `stream?.getTracks().forEach((t) => t.stop())` → `streamRef.value?.getTracks().forEach((t) => t.stop())` then `streamRef.value = null`
|
|
||||||
5. Return object: add `stream: readonly(streamRef)`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the `ref` import if not already present**
|
|
||||||
|
|
||||||
The file already imports `{ ref, readonly }` from `'vue'` — confirm this. If `ref` is missing from the import, add it.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Replace the `stream` variable declaration**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```ts
|
|
||||||
let stream: MediaStream | null = null
|
|
||||||
```
|
|
||||||
Replace with:
|
|
||||||
```ts
|
|
||||||
const streamRef = ref<MediaStream | null>(null)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update all usages of `stream` in `startRecording`**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```ts
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
||||||
```
|
|
||||||
Replace with:
|
|
||||||
```ts
|
|
||||||
streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update `onstop` handler**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```ts
|
|
||||||
stream?.getTracks().forEach((t) => t.stop())
|
|
||||||
stream = null
|
|
||||||
```
|
|
||||||
Replace with:
|
|
||||||
```ts
|
|
||||||
streamRef.value?.getTracks().forEach((t) => t.stop())
|
|
||||||
streamRef.value = null
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Add `stream` to the return object**
|
|
||||||
|
|
||||||
Find the return statement and add `stream: readonly(streamRef)`:
|
|
||||||
```ts
|
|
||||||
return {
|
|
||||||
recording: readonly(recording),
|
|
||||||
error: readonly(error),
|
|
||||||
isSupported,
|
|
||||||
startRecording,
|
|
||||||
stopRecording,
|
|
||||||
stream: readonly(streamRef),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/composables/useVoiceRecorder.ts
|
|
||||||
git commit -m "feat: expose live stream ref from useVoiceRecorder"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Update `VoiceOverlay` — silence detection, click-to-toggle, amplitude bars
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/components/VoiceOverlay.vue`
|
|
||||||
|
|
||||||
**Context:** `VoiceOverlay.vue` is a complete floating voice UI that was never mounted. It currently uses `@mousedown`/`@mouseup` for push-to-talk. This task switches it to click-to-toggle with automatic silence detection and adds animated amplitude bars during recording. Read the full file before making changes — the existing structure and style blocks must be preserved.
|
|
||||||
|
|
||||||
#### Script changes
|
|
||||||
|
|
||||||
- [ ] **Step 1: Import `useSilenceDetector`**
|
|
||||||
|
|
||||||
At the top of `<script setup>`, after the existing imports, add:
|
|
||||||
```ts
|
|
||||||
import { useSilenceDetector } from '@/composables/useSilenceDetector'
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Instantiate the composable**
|
|
||||||
|
|
||||||
After `const audio = useVoiceAudio()`, add:
|
|
||||||
```ts
|
|
||||||
const silenceDetector = useSilenceDetector()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update `startPtt` to start silence detection**
|
|
||||||
|
|
||||||
Find the `startPtt` function. After `phase.value = 'recording'`, add:
|
|
||||||
```ts
|
|
||||||
if (recorder.stream.value) {
|
|
||||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The complete `startPtt` after the change:
|
|
||||||
```ts
|
|
||||||
async function startPtt() {
|
|
||||||
if (!voiceEnabled.value || isBusy.value) return
|
|
||||||
audio.stop()
|
|
||||||
errorMsg.value = ''
|
|
||||||
open.value = true
|
|
||||||
await recorder.startRecording()
|
|
||||||
if (recorder.error.value) {
|
|
||||||
phase.value = 'error'
|
|
||||||
errorMsg.value = recorder.error.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
phase.value = 'recording'
|
|
||||||
if (recorder.stream.value) {
|
|
||||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update `stopPtt` to stop silence detection**
|
|
||||||
|
|
||||||
Add `silenceDetector.stop()` as the very first line of `stopPtt`:
|
|
||||||
```ts
|
|
||||||
async function stopPtt() {
|
|
||||||
silenceDetector.stop()
|
|
||||||
if (phase.value !== 'recording') return
|
|
||||||
// ... rest unchanged
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update `cancelAll` to stop silence detection**
|
|
||||||
|
|
||||||
Add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`:
|
|
||||||
```ts
|
|
||||||
function cancelAll() {
|
|
||||||
silenceDetector.stop()
|
|
||||||
recorder.stopRecording().catch(() => {})
|
|
||||||
audio.stop()
|
|
||||||
phase.value = 'idle'
|
|
||||||
streamContent.value = ''
|
|
||||||
errorMsg.value = ''
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Add `onBtnClick` function**
|
|
||||||
|
|
||||||
Add this function after `cancelAll`:
|
|
||||||
```ts
|
|
||||||
function onBtnClick() {
|
|
||||||
if (phase.value === 'error') { phase.value = 'idle'; return }
|
|
||||||
if (phase.value === 'recording') { stopPtt(); return }
|
|
||||||
if (phase.value === 'idle') { startPtt() }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Template changes
|
|
||||||
|
|
||||||
- [ ] **Step 7: Replace PTT mouse/touch handlers with `@click`**
|
|
||||||
|
|
||||||
On `.voice-ptt-btn`, replace:
|
|
||||||
```html
|
|
||||||
@mousedown.prevent="startPtt"
|
|
||||||
@mouseup.prevent="stopPtt"
|
|
||||||
@touchstart.prevent="startPtt"
|
|
||||||
@touchend.prevent="stopPtt"
|
|
||||||
@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"
|
|
||||||
```
|
|
||||||
with:
|
|
||||||
```html
|
|
||||||
@click.prevent="onBtnClick"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 8: Update aria-label and title on the button**
|
|
||||||
|
|
||||||
Replace:
|
|
||||||
```html
|
|
||||||
:aria-label="phase === 'recording' ? 'Release to send' : 'Hold to speak'"
|
|
||||||
:title="phase === 'recording' ? 'Release to send' : 'Hold Space or tap to speak'"
|
|
||||||
```
|
|
||||||
with:
|
|
||||||
```html
|
|
||||||
:aria-label="phase === 'recording' ? 'Click to stop' : 'Click to speak'"
|
|
||||||
:title="phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 9: Replace the static recording icon with amplitude bars**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```html
|
|
||||||
<!-- Recording: waveform / stop icon -->
|
|
||||||
<svg v-else-if="phase === 'recording'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
|
||||||
</svg>
|
|
||||||
```
|
|
||||||
Replace with:
|
|
||||||
```html
|
|
||||||
<!-- Recording: amplitude bars -->
|
|
||||||
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
|
|
||||||
<span
|
|
||||||
v-for="n in 3"
|
|
||||||
:key="n"
|
|
||||||
class="voice-amp-bar"
|
|
||||||
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 10: Update the idle hint label**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```html
|
|
||||||
Hold <kbd>Space</kbd> or tap
|
|
||||||
```
|
|
||||||
Replace with:
|
|
||||||
```html
|
|
||||||
Tap or press <kbd>Space</kbd>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Style changes
|
|
||||||
|
|
||||||
- [ ] **Step 11: Add amplitude bar styles to `<style scoped>`**
|
|
||||||
|
|
||||||
Append inside the `<style scoped>` block:
|
|
||||||
```css
|
|
||||||
/* ─── Amplitude bars (recording state) ──────────────────────────────────── */
|
|
||||||
.voice-amp-bars {
|
|
||||||
display: flex;
|
|
||||||
gap: 3px;
|
|
||||||
align-items: center;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
.voice-amp-bar {
|
|
||||||
width: 4px;
|
|
||||||
height: 18px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 2px;
|
|
||||||
transform-origin: center;
|
|
||||||
transition: transform 0.08s ease;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 12: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 13: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/components/VoiceOverlay.vue
|
|
||||||
git commit -m "feat: click-to-toggle silence detection and amplitude bars in VoiceOverlay"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Mount `VoiceOverlay` and wire Space bar in `App.vue`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/App.vue`
|
|
||||||
|
|
||||||
**Context:** `App.vue` has a full `onGlobalKeydown` handler and a shortcuts overlay. The Space bar is already documented there as "Hold to speak (voice, when enabled)" but the handler was never added to `onGlobalKeydown`. `VoiceOverlay` uses `Teleport to="body"` so it renders at the document root regardless of where it's placed in the template — just needs to be inside the authenticated block.
|
|
||||||
|
|
||||||
#### Script changes
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `VoiceOverlay` import**
|
|
||||||
|
|
||||||
In `<script setup>`, after the existing component imports (after `ToastNotification`), add:
|
|
||||||
```ts
|
|
||||||
import VoiceOverlay from '@/components/VoiceOverlay.vue'
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add Space bar case to `onGlobalKeydown`**
|
|
||||||
|
|
||||||
The existing handler has a `switch (e.key)` block. The guard `if (isInputActive() || e.ctrlKey || e.metaKey || e.altKey) return` already runs before the switch, so the Space case only fires when the user isn't typing.
|
|
||||||
|
|
||||||
Inside the `switch (e.key)` block, add this case after the existing `'c'` case:
|
|
||||||
```ts
|
|
||||||
case ' ':
|
|
||||||
e.preventDefault()
|
|
||||||
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
|
||||||
break
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Template changes
|
|
||||||
|
|
||||||
- [ ] **Step 3: Mount `VoiceOverlay` in the authenticated template**
|
|
||||||
|
|
||||||
Find `<ToastNotification />` near the bottom of the authenticated template block and add `<VoiceOverlay />` directly above it:
|
|
||||||
```html
|
|
||||||
<VoiceOverlay />
|
|
||||||
<ToastNotification />
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update Space bar description in shortcuts panel**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```html
|
|
||||||
<span class="shortcut-desc">Hold to speak (voice, when enabled)</span>
|
|
||||||
```
|
|
||||||
Replace with:
|
|
||||||
```html
|
|
||||||
<span class="shortcut-desc">Tap to speak (voice, when enabled)</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Verify full build succeeds**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: build completes with no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Manual smoke test**
|
|
||||||
|
|
||||||
1. Start the dev server: `npm run dev`
|
|
||||||
2. Log in — confirm the floating mic button appears in the bottom-right corner
|
|
||||||
3. Ensure voice is enabled in Settings → Voice
|
|
||||||
4. Click the mic button — confirm it turns red with animated amplitude bars
|
|
||||||
5. Speak — bars should animate with your voice
|
|
||||||
6. Stop speaking — after ~1.5 s of silence, the button should switch to purple (transcribing), then green (speaking) as it plays back the response
|
|
||||||
7. Click the mic while recording — confirm it stops immediately
|
|
||||||
8. Press Space (not in an input field) — confirm it starts/stops recording
|
|
||||||
9. Press Space in the chat input — confirm it does NOT trigger voice
|
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/App.vue
|
|
||||||
git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut in App.vue"
|
|
||||||
```
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
# Knowledge View Task Consolidation — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Consolidate tasks into the Knowledge view as a fifth card type, deprecate `/notes` and `/tasks` list routes, and simplify navigation down to a single Knowledge hub.
|
|
||||||
|
|
||||||
**Architecture:** The backend knowledge service (`services/knowledge.py`) stops excluding tasks from queries and adds `type=task` filtering via the `is_task` property (`Note.status IS NOT NULL`). The knowledge route validation gains `"task"` as a valid type. The frontend KnowledgeView gains task card rendering with status/priority/due-date badges. Router redirects replace the deleted list views.
|
|
||||||
|
|
||||||
**Tech Stack:** Python/Quart backend (SQLAlchemy), Vue 3 + TypeScript frontend, Pinia stores, Vue Router.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | Path |
|
|
||||||
|--------|------|
|
|
||||||
| Modify | `src/fabledassistant/services/knowledge.py` |
|
|
||||||
| Modify | `src/fabledassistant/routes/knowledge.py` |
|
|
||||||
| Modify | `frontend/src/views/KnowledgeView.vue` |
|
|
||||||
| Modify | `frontend/src/router/index.ts` |
|
|
||||||
| Modify | `frontend/src/components/AppHeader.vue` |
|
|
||||||
| Modify | `frontend/src/App.vue` |
|
|
||||||
| Delete | `frontend/src/views/NotesListView.vue` |
|
|
||||||
| Delete | `frontend/src/views/TasksListView.vue` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Backend — Include tasks in knowledge queries
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/knowledge.py`
|
|
||||||
- Modify: `src/fabledassistant/routes/knowledge.py`
|
|
||||||
|
|
||||||
**Context:** The knowledge service currently excludes tasks by filtering `Note.status.is_(None)`. Every query function (`query_knowledge`, `query_knowledge_ids`, `_semantic_knowledge_search`, `get_knowledge_tags`, `get_knowledge_counts`) has this exclusion. Adding task support means: (1) removing the task exclusion from the "all types" queries, (2) adding `type=task` as a filter option that maps to `Note.status.isnot(None)`, (3) enriching `_note_to_item` with task-specific fields, (4) updating counts to include tasks.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `"task"` to `_VALID_TYPES` in the route file**
|
|
||||||
|
|
||||||
In `src/fabledassistant/routes/knowledge.py`, change:
|
|
||||||
|
|
||||||
```python
|
|
||||||
_VALID_TYPES = {"note", "person", "place", "list"}
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
_VALID_TYPES = {"note", "person", "place", "list", "task"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update `_note_to_item` to include task fields**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/knowledge.py`, the `_note_to_item` function builds the item dict. After the existing `elif note.entity_type == "list":` block (which ends around line 48), add a task branch. Find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif note.entity_type == "list":
|
|
||||||
# Parse markdown task list syntax into structured items
|
|
||||||
body = note.body or ""
|
|
||||||
list_items = []
|
|
||||||
for line in body.split("\n"):
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
|
|
||||||
checked_item = not stripped.startswith("- [ ] ")
|
|
||||||
list_items.append({"text": stripped[6:], "checked": checked_item})
|
|
||||||
item["list_items"] = list_items
|
|
||||||
item["item_count"] = len(list_items)
|
|
||||||
item["checked_count"] = sum(1 for i in list_items if i["checked"])
|
|
||||||
item["body"] = body
|
|
||||||
return item
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif note.entity_type == "list":
|
|
||||||
# Parse markdown task list syntax into structured items
|
|
||||||
body = note.body or ""
|
|
||||||
list_items = []
|
|
||||||
for line in body.split("\n"):
|
|
||||||
stripped = line.strip()
|
|
||||||
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
|
|
||||||
checked_item = not stripped.startswith("- [ ] ")
|
|
||||||
list_items.append({"text": stripped[6:], "checked": checked_item})
|
|
||||||
item["list_items"] = list_items
|
|
||||||
item["item_count"] = len(list_items)
|
|
||||||
item["checked_count"] = sum(1 for i in list_items if i["checked"])
|
|
||||||
item["body"] = body
|
|
||||||
|
|
||||||
# Task fields — included for all items but only meaningful when is_task
|
|
||||||
if note.is_task:
|
|
||||||
item["note_type"] = "task"
|
|
||||||
item["status"] = note.status
|
|
||||||
item["priority"] = note.priority
|
|
||||||
item["due_date"] = note.due_date.isoformat() if note.due_date else None
|
|
||||||
|
|
||||||
return item
|
|
||||||
```
|
|
||||||
|
|
||||||
This overrides `note_type` to `"task"` for task items (since `entity_type` returns the `note_type` column which is `"note"` for tasks) and adds status/priority/due_date fields.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update `query_knowledge` to include tasks**
|
|
||||||
|
|
||||||
In the `query_knowledge` function, the "all types" filter currently excludes tasks. Change the base query and the `else` branch.
|
|
||||||
|
|
||||||
Find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
base = (
|
|
||||||
select(Note)
|
|
||||||
.where(Note.user_id == user_id)
|
|
||||||
.where(Note.status.is_(None)) # exclude tasks
|
|
||||||
)
|
|
||||||
|
|
||||||
if note_type:
|
|
||||||
base = base.where(Note.note_type == note_type)
|
|
||||||
else:
|
|
||||||
# Exclude tasks — already done above; also exclude any legacy nulls
|
|
||||||
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
base = select(Note).where(Note.user_id == user_id)
|
|
||||||
|
|
||||||
if note_type == "task":
|
|
||||||
base = base.where(Note.status.isnot(None))
|
|
||||||
elif note_type:
|
|
||||||
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
|
|
||||||
else:
|
|
||||||
# All types including tasks
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update `_semantic_knowledge_search` to include tasks**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
candidates = await semantic_search_notes(
|
|
||||||
user_id=user_id,
|
|
||||||
query=q,
|
|
||||||
limit=min(200, limit * 8),
|
|
||||||
threshold=0.3,
|
|
||||||
is_task=False,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
is_task_filter = True if note_type == "task" else (False if note_type else None)
|
|
||||||
candidates = await semantic_search_notes(
|
|
||||||
user_id=user_id,
|
|
||||||
query=q,
|
|
||||||
limit=min(200, limit * 8),
|
|
||||||
threshold=0.3,
|
|
||||||
is_task=is_task_filter,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Also update the type matching in the filter loop — find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
for _score, note in candidates:
|
|
||||||
if note_type and note.entity_type != note_type:
|
|
||||||
continue
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
for _score, note in candidates:
|
|
||||||
if note_type == "task" and not note.is_task:
|
|
||||||
continue
|
|
||||||
elif note_type and note_type != "task" and note.entity_type != note_type:
|
|
||||||
continue
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update `query_knowledge_ids` to include tasks**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
base = (
|
|
||||||
select(Note.id)
|
|
||||||
.where(Note.user_id == user_id)
|
|
||||||
.where(Note.status.is_(None))
|
|
||||||
)
|
|
||||||
if note_type:
|
|
||||||
base = base.where(Note.note_type == note_type)
|
|
||||||
else:
|
|
||||||
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
base = select(Note.id).where(Note.user_id == user_id)
|
|
||||||
|
|
||||||
if note_type == "task":
|
|
||||||
base = base.where(Note.status.isnot(None))
|
|
||||||
elif note_type:
|
|
||||||
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Update `get_knowledge_tags` to include task tags**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
base = (
|
|
||||||
select(func.unnest(Note.tags).label("tag"))
|
|
||||||
.where(Note.user_id == user_id)
|
|
||||||
.where(Note.status.is_(None))
|
|
||||||
)
|
|
||||||
if note_type:
|
|
||||||
base = base.where(Note.note_type == note_type)
|
|
||||||
else:
|
|
||||||
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
base = (
|
|
||||||
select(func.unnest(Note.tags).label("tag"))
|
|
||||||
.where(Note.user_id == user_id)
|
|
||||||
)
|
|
||||||
if note_type == "task":
|
|
||||||
base = base.where(Note.status.isnot(None))
|
|
||||||
elif note_type:
|
|
||||||
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Update `get_knowledge_counts` to include tasks**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async with async_session() as session:
|
|
||||||
stmt = (
|
|
||||||
select(Note.note_type, func.count(Note.id))
|
|
||||||
.where(Note.user_id == user_id)
|
|
||||||
.where(Note.status.is_(None))
|
|
||||||
.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
|
||||||
.group_by(Note.note_type)
|
|
||||||
)
|
|
||||||
if tags:
|
|
||||||
for tag in tags:
|
|
||||||
stmt = stmt.where(Note.tags.contains([tag]))
|
|
||||||
rows = list((await session.execute(stmt)).all())
|
|
||||||
counts = {row[0]: row[1] for row in rows}
|
|
||||||
# Ensure all types present even if zero
|
|
||||||
for t in ("note", "person", "place", "list"):
|
|
||||||
counts.setdefault(t, 0)
|
|
||||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list"))
|
|
||||||
return counts
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async with async_session() as session:
|
|
||||||
# Count non-task types
|
|
||||||
stmt = (
|
|
||||||
select(Note.note_type, func.count(Note.id))
|
|
||||||
.where(Note.user_id == user_id)
|
|
||||||
.where(Note.status.is_(None))
|
|
||||||
.where(Note.note_type.in_(["note", "person", "place", "list"]))
|
|
||||||
.group_by(Note.note_type)
|
|
||||||
)
|
|
||||||
if tags:
|
|
||||||
for tag in tags:
|
|
||||||
stmt = stmt.where(Note.tags.contains([tag]))
|
|
||||||
rows = list((await session.execute(stmt)).all())
|
|
||||||
counts = {row[0]: row[1] for row in rows}
|
|
||||||
|
|
||||||
# Count tasks separately (is_task = status IS NOT NULL)
|
|
||||||
task_stmt = (
|
|
||||||
select(func.count(Note.id))
|
|
||||||
.where(Note.user_id == user_id)
|
|
||||||
.where(Note.status.isnot(None))
|
|
||||||
)
|
|
||||||
if tags:
|
|
||||||
for tag in tags:
|
|
||||||
task_stmt = task_stmt.where(Note.tags.contains([tag]))
|
|
||||||
task_count: int = (await session.execute(task_stmt)).scalar_one()
|
|
||||||
counts["task"] = task_count
|
|
||||||
|
|
||||||
for t in ("note", "person", "place", "list", "task"):
|
|
||||||
counts.setdefault(t, 0)
|
|
||||||
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
|
|
||||||
return counts
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 8: Verify backend changes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/fabledassistant
|
|
||||||
make typecheck
|
|
||||||
make test
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no errors.
|
|
||||||
|
|
||||||
- [ ] **Step 9: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/knowledge.py src/fabledassistant/routes/knowledge.py
|
|
||||||
git commit -m "feat(knowledge): include tasks in knowledge queries and counts"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Frontend — Task card rendering in KnowledgeView
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
|
||||||
|
|
||||||
**Context:** `KnowledgeView.vue` has a `KnowledgeItem` interface and renders cards in a grid. Each card type has type-specific content (person shows relationship/email, list shows checkboxes, etc.). Task cards need status, priority, and due date display. The `activeType` ref controls filtering; it needs `"task"` as a valid value. The type filter sidebar needs a "Tasks" button. The new-note button interaction changes from split-button to toggle.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `"task"` to the KnowledgeItem interface and filter type**
|
|
||||||
|
|
||||||
In the `<script setup>` section, find the `KnowledgeItem` interface and add task fields:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface KnowledgeItem {
|
|
||||||
id: number;
|
|
||||||
note_type: "note" | "person" | "place" | "list";
|
|
||||||
// ... existing fields
|
|
||||||
```
|
|
||||||
|
|
||||||
Change to:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface KnowledgeItem {
|
|
||||||
id: number;
|
|
||||||
note_type: "note" | "person" | "place" | "list" | "task";
|
|
||||||
// ... existing fields
|
|
||||||
```
|
|
||||||
|
|
||||||
Also add the task-specific fields at the end of the interface (before the closing `}`):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Task-specific
|
|
||||||
status?: string;
|
|
||||||
priority?: string;
|
|
||||||
due_date?: string;
|
|
||||||
```
|
|
||||||
|
|
||||||
Update the `activeType` ref type:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const activeType = ref<"" | "note" | "person" | "place" | "list">("");
|
|
||||||
```
|
|
||||||
|
|
||||||
Change to:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add "Tasks" to the type filter sidebar**
|
|
||||||
|
|
||||||
Find the type filter `v-for` in the template:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button
|
|
||||||
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button
|
|
||||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
|
||||||
```
|
|
||||||
|
|
||||||
Update the type cast on the click handler. Find:
|
|
||||||
|
|
||||||
```html
|
|
||||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add task card content in the template**
|
|
||||||
|
|
||||||
In the card grid, find the note snippet section:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Note snippet -->
|
|
||||||
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a task-specific section above it:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Task specifics -->
|
|
||||||
<div v-else-if="item.note_type === 'task'" class="k-card-task">
|
|
||||||
<div class="task-badges">
|
|
||||||
<span class="status-badge" :class="`status--${item.status}`">
|
|
||||||
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="item.priority && item.priority !== 'none'"
|
|
||||||
class="priority-badge"
|
|
||||||
:class="`priority--${item.priority}`"
|
|
||||||
>{{ item.priority }}</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
v-if="item.due_date"
|
|
||||||
class="task-due"
|
|
||||||
:class="{ 'task-overdue': isOverdue(item) }"
|
|
||||||
>{{ formatDate(item.due_date) }}</span>
|
|
||||||
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Note snippet -->
|
|
||||||
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add `isOverdue` helper and update `openItem` for tasks**
|
|
||||||
|
|
||||||
In the `<script setup>`, add after the `formatDate` function:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function isOverdue(item: KnowledgeItem): boolean {
|
|
||||||
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
|
|
||||||
return new Date(item.due_date) < new Date(new Date().toDateString());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `openItem` to route tasks to their editor:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function openItem(item: KnowledgeItem) {
|
|
||||||
if (item.note_type === 'task') {
|
|
||||||
router.push(`/tasks/${item.id}`);
|
|
||||||
} else {
|
|
||||||
router.push(`/notes/${item.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update the "New note" button to toggle interaction**
|
|
||||||
|
|
||||||
Find the current new-note button markup:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="new-note-wrap">
|
|
||||||
<button class="btn-new-note" @click="createNew('note')">+ New note</button>
|
|
||||||
<button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type">▾</button>
|
|
||||||
<div v-if="newNoteMenuOpen" class="new-note-menu">
|
|
||||||
<button @click="createNew('note')">Note</button>
|
|
||||||
<button @click="createNew('person')">Person</button>
|
|
||||||
<button @click="createNew('place')">Place</button>
|
|
||||||
<button @click="createNew('list')">List</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="new-note-wrap">
|
|
||||||
<button class="btn-new-note" @click="newNoteMenuOpen ? createNew('note') : (newNoteMenuOpen = true)">+ New note</button>
|
|
||||||
<div v-if="newNoteMenuOpen" class="new-note-menu">
|
|
||||||
<button @click="createNew('task')">Task</button>
|
|
||||||
<button @click="createNew('person')">Person</button>
|
|
||||||
<button @click="createNew('place')">Place</button>
|
|
||||||
<button @click="createNew('list')">List</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a click-outside handler. In the `<script setup>`, add after the `createNew` function:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function onClickOutsideNewNote(e: MouseEvent) {
|
|
||||||
const wrap = document.querySelector('.new-note-wrap');
|
|
||||||
if (wrap && !wrap.contains(e.target as Node)) {
|
|
||||||
newNoteMenuOpen.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In `onMounted`, add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
document.addEventListener('click', onClickOutsideNewNote);
|
|
||||||
```
|
|
||||||
|
|
||||||
In `onUnmounted`, add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
document.removeEventListener('click', onClickOutsideNewNote);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Add task card CSS**
|
|
||||||
|
|
||||||
Append to the `<style scoped>` block:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* ── Task card ──────────────────────────────────────────── */
|
|
||||||
.k-card--task { border-left: 3px solid #a78bfa; }
|
|
||||||
|
|
||||||
.k-card-task {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.task-badges {
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.status-badge {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
|
|
||||||
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
|
|
||||||
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
|
|
||||||
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
|
|
||||||
|
|
||||||
.priority-badge {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
|
|
||||||
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
|
|
||||||
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
|
|
||||||
|
|
||||||
.task-due {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.task-overdue {
|
|
||||||
color: var(--color-overdue);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also add the task type badge color. Find:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Add after it:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.badge--task { background: rgba(167,139,250,0.15); color: #a78bfa; }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Remove the chevron button CSS**
|
|
||||||
|
|
||||||
Find and delete:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.btn-new-chevron {
|
|
||||||
padding: 7px 9px;
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
border: 1px solid rgba(99, 102, 241, 0.4);
|
|
||||||
background: rgba(99, 102, 241, 0.12);
|
|
||||||
color: var(--color-primary, #818cf8);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1;
|
|
||||||
transition: background 0.15s, transform 0.15s;
|
|
||||||
}
|
|
||||||
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
|
|
||||||
.btn-new-chevron.open { transform: scaleY(-1); }
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `.btn-new-note` to have full border-radius now that the chevron is gone:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.btn-new-note {
|
|
||||||
flex: 1;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(99, 102, 241, 0.4);
|
|
||||||
background: rgba(99, 102, 241, 0.12);
|
|
||||||
color: var(--color-primary, #818cf8);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 8: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/fabledassistant/frontend
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no new errors (pre-existing TipTap errors are fine).
|
|
||||||
|
|
||||||
- [ ] **Step 9: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/views/KnowledgeView.vue
|
|
||||||
git commit -m "feat(knowledge): add task cards with status/priority/due-date display"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Route redirects, navigation cleanup, dead code removal
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/router/index.ts`
|
|
||||||
- Modify: `frontend/src/components/AppHeader.vue`
|
|
||||||
- Modify: `frontend/src/App.vue`
|
|
||||||
- Delete: `frontend/src/views/NotesListView.vue`
|
|
||||||
- Delete: `frontend/src/views/TasksListView.vue`
|
|
||||||
|
|
||||||
**Context:** The router currently has `/notes` and `/tasks` pointing to list view components. These become redirects to `/`. The AppHeader has "Tasks" in both desktop and mobile nav. The `g+t` keyboard shortcut navigates to `/tasks` which should change to `/`. The stores (`notes.ts`, `tasks.ts`) are used by other views so they stay.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace list view routes with redirects**
|
|
||||||
|
|
||||||
In `frontend/src/router/index.ts`, find:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
path: "/notes",
|
|
||||||
name: "notes",
|
|
||||||
component: () => import("@/views/NotesListView.vue"),
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
path: "/notes",
|
|
||||||
redirect: "/",
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
Find:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
path: "/tasks",
|
|
||||||
name: "tasks",
|
|
||||||
component: () => import("@/views/TasksListView.vue"),
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
path: "/tasks",
|
|
||||||
redirect: "/",
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Remove "Tasks" from AppHeader navigation**
|
|
||||||
|
|
||||||
In `frontend/src/components/AppHeader.vue`, find in the desktop nav-center:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
|
||||||
```
|
|
||||||
|
|
||||||
Delete this line.
|
|
||||||
|
|
||||||
Find in the mobile dropdown menu:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
|
||||||
```
|
|
||||||
|
|
||||||
Delete this line.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update `g+t` keyboard shortcut in App.vue**
|
|
||||||
|
|
||||||
In `frontend/src/App.vue`, find in the `onGlobalKeydown` function, inside the `if (pendingPrefix === "g")` block:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
case "t": router.push("/tasks"); break;
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
case "t": router.push("/"); break;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update shortcuts overlay text**
|
|
||||||
|
|
||||||
In `frontend/src/App.vue`, find in the shortcuts overlay template:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<kbd class="shortcut-key">t</kbd>
|
|
||||||
<span class="shortcut-desc">Tasks</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace the description:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<kbd class="shortcut-key">t</kbd>
|
|
||||||
<span class="shortcut-desc">Knowledge (tasks)</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Delete the deprecated list view files**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm frontend/src/views/NotesListView.vue
|
|
||||||
rm frontend/src/views/TasksListView.vue
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Verify build**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/fabledassistant/frontend
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no new errors. The deleted files were only imported via lazy `() => import(...)` in the router, which we already replaced with redirects.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A frontend/src/views/NotesListView.vue frontend/src/views/TasksListView.vue \
|
|
||||||
frontend/src/router/index.ts frontend/src/components/AppHeader.vue frontend/src/App.vue
|
|
||||||
git commit -m "feat: deprecate /notes and /tasks routes; redirect to Knowledge view"
|
|
||||||
```
|
|
||||||
@@ -1,786 +0,0 @@
|
|||||||
# Specialized Note Type Editors — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Replace the generic note editor with type-specialized form-first views for Person, Place, and List, and fix tab navigation so focus flows logically from title through content fields, skipping the formatting toolbar.
|
|
||||||
|
|
||||||
**Architecture:** `NoteEditorView.vue` gains type-conditional template sections. When `noteType` is `person` or `place`, the main editor area renders a structured form with the TipTap editor in a secondary "Notes" section. When `noteType` is `list`, a dedicated list builder replaces TipTap as the primary interface. `MarkdownToolbar.vue` gets `tabindex="-1"` on buttons. Backend `_note_to_item` gains new person/place fields.
|
|
||||||
|
|
||||||
**Tech Stack:** Vue 3 Composition API, TipTap editor, TypeScript, scoped CSS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | Path |
|
|
||||||
|--------|------|
|
|
||||||
| Modify | `frontend/src/views/NoteEditorView.vue` |
|
|
||||||
| Modify | `frontend/src/components/MarkdownToolbar.vue` |
|
|
||||||
| Modify | `frontend/src/views/KnowledgeView.vue` |
|
|
||||||
| Modify | `src/fabledassistant/services/knowledge.py` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Tab navigation fix — toolbar tabindex + auto-focus
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/components/MarkdownToolbar.vue`
|
|
||||||
- Modify: `frontend/src/views/NoteEditorView.vue`
|
|
||||||
|
|
||||||
**Context:** The MarkdownToolbar renders buttons via `v-for` in a single `<button>` element. Adding `tabindex="-1"` removes them from tab order while keeping them clickable. The NoteEditorView already has a `titleRef` — auto-focus on mount needs to call `.focus()` on it. The title placeholder should vary by note type.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add tabindex="-1" to toolbar buttons**
|
|
||||||
|
|
||||||
In `frontend/src/components/MarkdownToolbar.vue`, find:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button
|
|
||||||
v-for="btn in group"
|
|
||||||
:key="btn.id"
|
|
||||||
:class="['md-btn', { active: btn.isActive() }]"
|
|
||||||
:title="btn.title"
|
|
||||||
type="button"
|
|
||||||
@mousedown.prevent="btn.command()"
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button
|
|
||||||
v-for="btn in group"
|
|
||||||
:key="btn.id"
|
|
||||||
:class="['md-btn', { active: btn.isActive() }]"
|
|
||||||
:title="btn.title"
|
|
||||||
type="button"
|
|
||||||
tabindex="-1"
|
|
||||||
@mousedown.prevent="btn.command()"
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add auto-focus on mount and type-dependent placeholder**
|
|
||||||
|
|
||||||
In `frontend/src/views/NoteEditorView.vue`, find the title input:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<input
|
|
||||||
ref="titleRef"
|
|
||||||
v-model="title"
|
|
||||||
type="text"
|
|
||||||
placeholder="Title"
|
|
||||||
class="title-input"
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<input
|
|
||||||
ref="titleRef"
|
|
||||||
v-model="title"
|
|
||||||
type="text"
|
|
||||||
:placeholder="titlePlaceholder"
|
|
||||||
class="title-input"
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the computed property in the `<script setup>` section, after the `isEditing` computed:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const titlePlaceholder = computed(() => {
|
|
||||||
switch (noteType.value) {
|
|
||||||
case 'person': return 'Name';
|
|
||||||
case 'place': return 'Place name';
|
|
||||||
case 'list': return 'List title';
|
|
||||||
default: return 'Title';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Auto-focus title on mount**
|
|
||||||
|
|
||||||
In the `onMounted` callback, after all the data loading logic (after the draft restore try/catch block), add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
await nextTick();
|
|
||||||
titleRef.value?.focus();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/components/MarkdownToolbar.vue frontend/src/views/NoteEditorView.vue
|
|
||||||
git commit -m "feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Person editor — form-first layout
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/views/NoteEditorView.vue`
|
|
||||||
|
|
||||||
**Context:** When `noteType === 'person'`, the main content area should render a contact card form instead of the TipTap-first editor. The person metadata fields (currently in the sidebar) move to the main area, and new fields (birthday, organization, address) are added. The TipTap editor becomes a collapsible "Notes" section below. The sidebar keeps project/tags/type/etc but loses the person-specific fields.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the person form template**
|
|
||||||
|
|
||||||
In the template, find the `<!-- ── Main column ──` section. The current structure is:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
|
||||||
<div class="body-tabs-row">
|
|
||||||
...
|
|
||||||
</div>
|
|
||||||
<!-- Streaming/Review/Normal editor templates -->
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Wrap the existing main column content in a `v-if="noteType === 'note'"` (and also show it for any type not person/place/list), and add a person form block. Replace the opening of the main column content:
|
|
||||||
|
|
||||||
Find the `<div class="note-main"` line and the content inside it up to `</div>` that closes `.note-main`. Wrap all existing content inside:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="note-main">
|
|
||||||
<!-- ── Person form ──────────────────────────────────────── -->
|
|
||||||
<template v-if="noteType === 'person'">
|
|
||||||
<div class="entity-form">
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Relationship</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Birthday</label>
|
|
||||||
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Email</label>
|
|
||||||
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Phone</label>
|
|
||||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Organization</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Address</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, wikilinks, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── Generic note editor (existing) ───────────────────── -->
|
|
||||||
<template v-else-if="noteType === 'note'">
|
|
||||||
<!-- ... existing TipTap-first editor content stays here ... -->
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
IMPORTANT: Do NOT duplicate the existing editor content. Wrap the existing content in `<template v-else-if="noteType === 'note'">` and place the person form as a sibling `<template>` above it. The place and list forms will be added in subsequent tasks.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `notesExpanded` ref**
|
|
||||||
|
|
||||||
In the `<script setup>`, after the `sidebarOpen` ref, add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const notesExpanded = ref(false);
|
|
||||||
```
|
|
||||||
|
|
||||||
Also initialize it based on whether the note has body content, in the onMounted data-loading section. After `Object.assign(entityMeta, store.currentNote.metadata || {});` add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
notesExpanded.value = !!(store.currentNote.body || '').trim();
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Remove person fields from sidebar**
|
|
||||||
|
|
||||||
In the sidebar template, find:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Person metadata -->
|
|
||||||
<template v-if="noteType === 'person'">
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Relationship</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Email</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Phone</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
Delete this entire block.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add entity form CSS**
|
|
||||||
|
|
||||||
Add to the `<style scoped>` block:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* ── Entity form (Person / Place) ───────────────────────── */
|
|
||||||
.entity-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
.ef-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.ef-label {
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.ef-input {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.ef-input:focus {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: var(--focus-ring);
|
|
||||||
}
|
|
||||||
.ef-input::placeholder {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Notes section (collapsible TipTap) ─────────────────── */
|
|
||||||
.notes-section {
|
|
||||||
margin-top: 16px;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
padding-top: 12px;
|
|
||||||
}
|
|
||||||
.notes-toggle {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
.notes-toggle:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.notes-editor-wrap {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/views/NoteEditorView.vue
|
|
||||||
git commit -m "feat(editor): person form-first layout with structured fields and collapsible notes"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Place editor + List builder
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/views/NoteEditorView.vue`
|
|
||||||
|
|
||||||
**Context:** Place uses the same entity form pattern as Person with different fields. List uses a dedicated checklist builder with Enter-to-add and Backspace-to-delete behavior. Both are additional `<template>` branches in the main column.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add place form template**
|
|
||||||
|
|
||||||
In the `note-main` div, after the person `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- ── Place form ───────────────────────────────────────── -->
|
|
||||||
<template v-else-if="noteType === 'place'">
|
|
||||||
<div class="entity-form">
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Address</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Phone</label>
|
|
||||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Hours</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9am–5pm" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Website</label>
|
|
||||||
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Category</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, wikilinks, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Remove place fields from sidebar**
|
|
||||||
|
|
||||||
Find and delete:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Place metadata -->
|
|
||||||
<template v-if="noteType === 'place'">
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Address</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Phone</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="sb-field">
|
|
||||||
<label class="sb-label">Hours</label>
|
|
||||||
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9–5" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add list item types and state**
|
|
||||||
|
|
||||||
In the `<script setup>`, after the `notesExpanded` ref, add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// ── List builder ─────────────────────────────────────────────────────────────
|
|
||||||
interface ListItem {
|
|
||||||
text: string;
|
|
||||||
checked: boolean;
|
|
||||||
}
|
|
||||||
const listItems = ref<ListItem[]>([]);
|
|
||||||
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
|
|
||||||
|
|
||||||
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
|
|
||||||
const lines = bodyText.split('\n');
|
|
||||||
const items: ListItem[] = [];
|
|
||||||
const extraLines: string[] = [];
|
|
||||||
let pastList = false;
|
|
||||||
for (const line of lines) {
|
|
||||||
const stripped = line.trimStart();
|
|
||||||
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
|
|
||||||
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
|
|
||||||
} else if (!pastList && stripped === '' && items.length > 0) {
|
|
||||||
pastList = true;
|
|
||||||
} else {
|
|
||||||
pastList = true;
|
|
||||||
extraLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { items, extra: extraLines.join('\n').trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeListToBody(): string {
|
|
||||||
const listPart = listItems.value
|
|
||||||
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
|
||||||
.join('\n');
|
|
||||||
const extraPart = body.value.trim();
|
|
||||||
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addListItem(afterIndex?: number) {
|
|
||||||
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
|
|
||||||
listItems.value.splice(idx, 0, { text: '', checked: false });
|
|
||||||
markDirty();
|
|
||||||
nextTick(() => {
|
|
||||||
listItemRefs.value[idx]?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeListItem(index: number) {
|
|
||||||
if (listItems.value.length <= 1) return;
|
|
||||||
listItems.value.splice(index, 1);
|
|
||||||
markDirty();
|
|
||||||
nextTick(() => {
|
|
||||||
const focusIdx = Math.max(0, index - 1);
|
|
||||||
listItemRefs.value[focusIdx]?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onListItemKeydown(e: KeyboardEvent, index: number) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
addListItem(index);
|
|
||||||
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
|
|
||||||
e.preventDefault();
|
|
||||||
removeListItem(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onListItemInput(index: number) {
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleListItemCheck(index: number) {
|
|
||||||
listItems.value[index].checked = !listItems.value[index].checked;
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Initialize list items on mount**
|
|
||||||
|
|
||||||
In the onMounted data-loading section, after `notesExpanded.value = !!(store.currentNote.body || '').trim();`, add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (noteType.value === 'list') {
|
|
||||||
const parsed = parseListFromBody(body.value);
|
|
||||||
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
|
|
||||||
body.value = parsed.extra;
|
|
||||||
notesExpanded.value = !!parsed.extra;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And in the new-note branch (the `else` block after loading), after `noteType.value = qt as NoteType;`, add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (noteType.value === 'list') {
|
|
||||||
listItems.value = [{ text: '', checked: false }];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update save to serialize list**
|
|
||||||
|
|
||||||
In the `save` function, find where the body is prepared for the API call. Before the `apiPost` or `apiPatch` call that sends the note data, add list serialization. Find the save function's data construction. Add before the API call:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
|
||||||
```
|
|
||||||
|
|
||||||
Then use `finalBody` instead of `body.value` in the API payload. Find all occurrences of `body: body.value` in the save function and replace with `body: finalBody`.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Add list builder template**
|
|
||||||
|
|
||||||
In the `note-main` div, after the place `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- ── List builder ─────────────────────────────────────── -->
|
|
||||||
<template v-else-if="noteType === 'list'">
|
|
||||||
<div class="list-builder">
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in listItems"
|
|
||||||
:key="idx"
|
|
||||||
class="lb-item"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="item.checked"
|
|
||||||
@change="toggleListItemCheck(idx)"
|
|
||||||
class="lb-check"
|
|
||||||
tabindex="-1"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
|
|
||||||
v-model="item.text"
|
|
||||||
class="lb-text"
|
|
||||||
placeholder="List item..."
|
|
||||||
@keydown="onListItemKeydown($event, idx)"
|
|
||||||
@input="onListItemInput(idx)"
|
|
||||||
/>
|
|
||||||
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">×</button>
|
|
||||||
</div>
|
|
||||||
<button class="lb-add" @click="addListItem()">+ Add item</button>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Add list builder CSS**
|
|
||||||
|
|
||||||
Add to the `<style scoped>` block:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* ── List builder ───────────────────────────────────────── */
|
|
||||||
.list-builder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
.lb-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.lb-check {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.lb-text {
|
|
||||||
flex: 1;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.lb-text:focus {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: var(--focus-ring);
|
|
||||||
}
|
|
||||||
.lb-text::placeholder {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.lb-delete {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
.lb-item:hover .lb-delete,
|
|
||||||
.lb-text:focus ~ .lb-delete {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.lb-delete:hover {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.lb-add {
|
|
||||||
background: none;
|
|
||||||
border: 1px dashed var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 4px;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.lb-add:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 8: Handle the generic note template wrapper**
|
|
||||||
|
|
||||||
Make sure the existing TipTap-first editor content is wrapped in `<template v-else>` (not `v-else-if="noteType === 'note'"`) so it serves as the default for any unrecognized type.
|
|
||||||
|
|
||||||
The final structure in `.note-main` should be:
|
|
||||||
|
|
||||||
```
|
|
||||||
<template v-if="noteType === 'person'"> ... </template>
|
|
||||||
<template v-else-if="noteType === 'place'"> ... </template>
|
|
||||||
<template v-else-if="noteType === 'list'"> ... </template>
|
|
||||||
<template v-else> ... existing TipTap editor ... </template>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 9: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 10: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/views/NoteEditorView.vue
|
|
||||||
git commit -m "feat(editor): place form-first layout and list builder with Enter-to-add"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Backend — new person/place fields in knowledge cards
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/knowledge.py`
|
|
||||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
|
||||||
|
|
||||||
**Context:** The knowledge card display should show the new fields (birthday, organization for person; website, category for place). The backend `_note_to_item` needs to include them. The frontend card rendering needs to display the useful ones.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update `_note_to_item` for person**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/knowledge.py`, find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if note.entity_type == "person":
|
|
||||||
item["relationship"] = meta.get("relationship", "")
|
|
||||||
item["email"] = meta.get("email", "")
|
|
||||||
item["phone"] = meta.get("phone", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if note.entity_type == "person":
|
|
||||||
item["relationship"] = meta.get("relationship", "")
|
|
||||||
item["email"] = meta.get("email", "")
|
|
||||||
item["phone"] = meta.get("phone", "")
|
|
||||||
item["birthday"] = meta.get("birthday", "")
|
|
||||||
item["organization"] = meta.get("organization", "")
|
|
||||||
item["address"] = meta.get("address", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update `_note_to_item` for place**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif note.entity_type == "place":
|
|
||||||
item["address"] = meta.get("address", "")
|
|
||||||
item["phone"] = meta.get("phone", "")
|
|
||||||
item["hours"] = meta.get("hours", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif note.entity_type == "place":
|
|
||||||
item["address"] = meta.get("address", "")
|
|
||||||
item["phone"] = meta.get("phone", "")
|
|
||||||
item["hours"] = meta.get("hours", "")
|
|
||||||
item["website"] = meta.get("website", "")
|
|
||||||
item["category"] = meta.get("category", "")
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update KnowledgeItem interface**
|
|
||||||
|
|
||||||
In `frontend/src/views/KnowledgeView.vue`, find the `KnowledgeItem` interface and add the new fields:
|
|
||||||
|
|
||||||
After `phone?: string;` add:
|
|
||||||
```ts
|
|
||||||
birthday?: string;
|
|
||||||
organization?: string;
|
|
||||||
```
|
|
||||||
|
|
||||||
After `hours?: string;` add:
|
|
||||||
```ts
|
|
||||||
website?: string;
|
|
||||||
category?: string;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update person card display**
|
|
||||||
|
|
||||||
In the template, find the person card specifics:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
|
||||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
|
||||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
|
||||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
|
||||||
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
|
|
||||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update place card display**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
|
||||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
|
||||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
|
||||||
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
|
|
||||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
|
||||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Verify TypeScript compiles and backend syntax**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
python -c "import ast; ast.parse(open('src/fabledassistant/services/knowledge.py').read()); print('OK')"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/knowledge.py frontend/src/views/KnowledgeView.vue
|
|
||||||
git commit -m "feat(knowledge): show organization/birthday for person cards, category for place cards"
|
|
||||||
```
|
|
||||||
@@ -1,785 +0,0 @@
|
|||||||
# Modern Fable Visual Identity — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Replace the generic indigo dark-mode palette with a distinctive "Modern Fable" visual identity — deep violet + muted gold, signature card types, pill nav, Fraunces-as-narrator typography, and living micro-details.
|
|
||||||
|
|
||||||
**Architecture:** Pure frontend changes across theme CSS, AppHeader, AppLogo, KnowledgeView, ChatPanel, BriefingView, and CalendarView. No backend changes. Each task is independently deployable — palette first, then cards, then nav, then typography, then details.
|
|
||||||
|
|
||||||
**Tech Stack:** Vue 3 SFC (scoped CSS), CSS custom properties, Fraunces font (already loaded).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | Path |
|
|
||||||
|--------|------|
|
|
||||||
| Modify | `frontend/src/assets/theme.css` |
|
|
||||||
| Modify | `frontend/src/components/AppLogo.vue` |
|
|
||||||
| Modify | `frontend/src/components/AppHeader.vue` |
|
|
||||||
| Modify | `frontend/src/views/KnowledgeView.vue` |
|
|
||||||
| Modify | `frontend/src/components/ChatPanel.vue` |
|
|
||||||
| Modify | `frontend/src/views/BriefingView.vue` |
|
|
||||||
| Modify | `frontend/src/views/CalendarView.vue` |
|
|
||||||
| Modify | `frontend/src/App.vue` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Color palette update + logo + scrollbar
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/assets/theme.css`
|
|
||||||
- Modify: `frontend/src/components/AppLogo.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update the dark theme palette in theme.css**
|
|
||||||
|
|
||||||
In `frontend/src/assets/theme.css`, find the `[data-theme="dark"]` block and replace these values:
|
|
||||||
|
|
||||||
```css
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--color-bg: #0f0f14;
|
|
||||||
--color-bg-secondary: #16161f;
|
|
||||||
--color-bg-card: #1a1a24;
|
|
||||||
--color-surface: #16161f;
|
|
||||||
--color-text: #e4e4f0;
|
|
||||||
--color-text-secondary: #8888a8;
|
|
||||||
--color-text-muted: #52526a;
|
|
||||||
--color-border: rgba(124, 58, 237, 0.12);
|
|
||||||
--color-input-border: rgba(124, 58, 237, 0.22);
|
|
||||||
--color-primary: #a78bfa;
|
|
||||||
--color-danger: #f44336;
|
|
||||||
--color-tag-bg: #2a2a45;
|
|
||||||
--color-tag-text: #c4b5fd;
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
|
||||||
--color-toast-success: #4caf50;
|
|
||||||
--color-toast-error: #f44336;
|
|
||||||
--color-status-todo: #9aa0a6;
|
|
||||||
--color-status-todo-bg: #2a2a35;
|
|
||||||
--color-status-in-progress: #a78bfa;
|
|
||||||
--color-status-in-progress-bg: #2a2a45;
|
|
||||||
--color-status-done: #4caf50;
|
|
||||||
--color-status-done-bg: #1b3a20;
|
|
||||||
--color-priority-low: #80cbc4;
|
|
||||||
--color-priority-low-bg: #1a3a38;
|
|
||||||
--color-priority-medium: #fdd835;
|
|
||||||
--color-priority-medium-bg: #3a3520;
|
|
||||||
--color-priority-high: #f44336;
|
|
||||||
--color-priority-high-bg: #3a1a1a;
|
|
||||||
--color-wikilink: #c4b5fd;
|
|
||||||
--color-wikilink-bg: #2a1a45;
|
|
||||||
--color-overdue: #f44336;
|
|
||||||
--color-code-bg: #12121a;
|
|
||||||
--color-code-inline-bg: #1a1a2a;
|
|
||||||
--color-table-stripe: #14141e;
|
|
||||||
--color-success: #4ade80;
|
|
||||||
--color-warning: #facc15;
|
|
||||||
--color-input-bar-bg: #1a1a24;
|
|
||||||
--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(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
|
||||||
--color-accent-warm: #d4a017;
|
|
||||||
--color-accent-warm-light: #e8c45a;
|
|
||||||
--color-primary-solid: #7c3aed;
|
|
||||||
--color-primary-deep: #5b21b6;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `--color-accent-warm`, `--color-accent-warm-light`, `--color-primary-solid`, and `--color-primary-deep` are new variables.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update the light theme palette**
|
|
||||||
|
|
||||||
In the `:root` block, update these values:
|
|
||||||
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--color-bg: #f5f5fb;
|
|
||||||
--color-bg-secondary: #ededf5;
|
|
||||||
--color-bg-card: #ffffff;
|
|
||||||
--color-surface: #f0f0f8;
|
|
||||||
--color-text: #1a1a1a;
|
|
||||||
--color-text-secondary: #666666;
|
|
||||||
--color-text-muted: #999999;
|
|
||||||
--color-border: #dddde8;
|
|
||||||
--color-input-border: #c8c8d8;
|
|
||||||
--color-primary: #7c3aed;
|
|
||||||
--color-danger: #d93025;
|
|
||||||
--color-tag-bg: #ede5ff;
|
|
||||||
--color-tag-text: #6d28d9;
|
|
||||||
--color-shadow: rgba(0, 0, 0, 0.08);
|
|
||||||
--color-toast-success: #34a853;
|
|
||||||
--color-toast-error: #d93025;
|
|
||||||
--color-status-todo: #5f6368;
|
|
||||||
--color-status-todo-bg: #e8eaed;
|
|
||||||
--color-status-in-progress: #7c3aed;
|
|
||||||
--color-status-in-progress-bg: #ede5ff;
|
|
||||||
--color-status-done: #34a853;
|
|
||||||
--color-status-done-bg: #e6f4ea;
|
|
||||||
--color-priority-low: #5f9ea0;
|
|
||||||
--color-priority-low-bg: #e0f2f1;
|
|
||||||
--color-priority-medium: #f9a825;
|
|
||||||
--color-priority-medium-bg: #fff8e1;
|
|
||||||
--color-priority-high: #d93025;
|
|
||||||
--color-priority-high-bg: #fce8e6;
|
|
||||||
--color-wikilink: #7b1fa2;
|
|
||||||
--color-wikilink-bg: #f3e5f5;
|
|
||||||
--color-overdue: #d93025;
|
|
||||||
--color-code-bg: #f0f0f8;
|
|
||||||
--color-code-inline-bg: #eaeaf4;
|
|
||||||
--color-table-stripe: #f4f4fb;
|
|
||||||
--color-success: #22c55e;
|
|
||||||
--color-warning: #eab308;
|
|
||||||
--color-input-bar-bg: #eaeaf3;
|
|
||||||
--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(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
|
|
||||||
--radius-sm: 6px;
|
|
||||||
--radius-md: 12px;
|
|
||||||
--radius-lg: 18px;
|
|
||||||
--radius-pill: 9999px;
|
|
||||||
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
|
|
||||||
/* Layout */
|
|
||||||
--page-max-width: 1200px;
|
|
||||||
--page-padding-x: 1rem;
|
|
||||||
--sidebar-width: 260px;
|
|
||||||
/* New brand variables */
|
|
||||||
--color-accent-warm: #b8860b;
|
|
||||||
--color-accent-warm-light: #d4a017;
|
|
||||||
--color-primary-solid: #7c3aed;
|
|
||||||
--color-primary-deep: #5b21b6;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update scrollbar color**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```css
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(99, 102, 241, 0.25);
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(99, 102, 241, 0.45);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(124, 58, 237, 0.25);
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(124, 58, 237, 0.45);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update focus ring**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```css
|
|
||||||
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update AppLogo gradient**
|
|
||||||
|
|
||||||
In `frontend/src/components/AppLogo.vue`, the logo uses `var(--color-primary)` which will automatically pick up the new violet value. No code change needed — the CSS variable update handles it.
|
|
||||||
|
|
||||||
However, add a gradient `<defs>` for the book fill to use the deep gradient instead of a flat color. Find the `<style scoped>` block:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.logo-book {
|
|
||||||
fill: var(--color-primary);
|
|
||||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.logo-book {
|
|
||||||
fill: url(#logo-gradient);
|
|
||||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And add a gradient definition inside the `<svg>` element, before the `<!-- Book body -->` comment:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="var(--color-primary-solid)" />
|
|
||||||
<stop offset="100%" stop-color="var(--color-primary-deep)" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/assets/theme.css frontend/src/components/AppLogo.vue
|
|
||||||
git commit -m "feat(theme): shift palette from indigo to deep violet + muted gold"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Signature header — pill nav + brand shortening + status pulse
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/components/AppHeader.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update brand text in header**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```html
|
|
||||||
Fabled Assistant
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```html
|
|
||||||
<span class="brand-text">Fabled</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Wrap nav-center links in a pill container**
|
|
||||||
|
|
||||||
Find the `nav-center` div:
|
|
||||||
```html
|
|
||||||
<div class="nav-center">
|
|
||||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
|
||||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
|
||||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
|
||||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
|
||||||
<router-link to="/news" class="nav-link">News</router-link>
|
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```html
|
|
||||||
<div class="nav-center">
|
|
||||||
<div class="nav-pill-bar">
|
|
||||||
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
|
||||||
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
|
|
||||||
<router-link to="/briefing" class="nav-link">Briefing</router-link>
|
|
||||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
|
||||||
<router-link to="/news" class="nav-link">News</router-link>
|
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Replace header and nav CSS**
|
|
||||||
|
|
||||||
Replace the entire `<style scoped>` from `.app-header` through `.nav-link.router-link-active` with:
|
|
||||||
|
|
||||||
```css
|
|
||||||
.app-header {
|
|
||||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
|
||||||
border-bottom: 1px solid rgba(124, 58, 237, 0.08);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.nav {
|
|
||||||
padding: 0.6rem 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Left — brand */
|
|
||||||
.nav-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
text-decoration: none;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.brand-text {
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-optical-sizing: auto;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
color: #c4b0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Center — pill bar */
|
|
||||||
.nav-center {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.nav-pill-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
background: rgba(124, 58, 237, 0.06);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right */
|
|
||||||
.nav-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.nav-link:hover {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
background: rgba(124, 58, 237, 0.08);
|
|
||||||
}
|
|
||||||
.nav-link.router-link-active {
|
|
||||||
color: #c4b5fd;
|
|
||||||
font-weight: 600;
|
|
||||||
background: rgba(124, 58, 237, 0.2);
|
|
||||||
box-shadow: 0 0 12px rgba(124, 58, 237, 0.2);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add status dot pulse animation for loaded state**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```css
|
|
||||||
.status-green .status-dot { background: var(--color-success, #2ecc71); }
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```css
|
|
||||||
@keyframes pulse-dot {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add after it:
|
|
||||||
```css
|
|
||||||
@keyframes status-pulse {
|
|
||||||
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
|
|
||||||
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update mobile menu active styling**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```css
|
|
||||||
.mobile-menu .nav-link {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
min-height: 44px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.mobile-menu .nav-link {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
min-height: 44px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.mobile-menu .nav-link.router-link-active {
|
|
||||||
background: rgba(124, 58, 237, 0.15);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/components/AppHeader.vue
|
|
||||||
git commit -m "feat(header): pill nav bar, brand shortening, status pulse, header gradient"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Card type DNA — gradient bars, corner accents, hover bloom
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/views/KnowledgeView.vue`
|
|
||||||
|
|
||||||
**Context:** The cards currently have a left accent strip per type. The new design replaces this with top gradient bars (notes, tasks, lists) and corner accents (person, place), plus a unified violet hover bloom.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace card accent strips with type-specific top bars and borders**
|
|
||||||
|
|
||||||
Find in the `<style scoped>`:
|
|
||||||
```css
|
|
||||||
/* Type accent strip */
|
|
||||||
.k-card--person { border-left: 3px solid #10b981; }
|
|
||||||
.k-card--place { border-left: 3px solid #f59e0b; }
|
|
||||||
.k-card--list { border-left: 3px solid #38bdf8; }
|
|
||||||
.k-card--note { border-left: 3px solid #6366f1; }
|
|
||||||
.k-card--task { border-left: 3px solid #a78bfa; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
/* Type-specific card DNA */
|
|
||||||
.k-card--note { border-color: rgba(124, 58, 237, 0.12); }
|
|
||||||
.k-card--task { border-color: rgba(212, 160, 23, 0.10); }
|
|
||||||
.k-card--person { border-color: rgba(16, 185, 129, 0.10); }
|
|
||||||
.k-card--place { border-color: rgba(245, 158, 11, 0.10); }
|
|
||||||
.k-card--list { border-color: rgba(56, 189, 248, 0.10); }
|
|
||||||
|
|
||||||
/* Top gradient bars */
|
|
||||||
.k-card--note::before,
|
|
||||||
.k-card--task::before,
|
|
||||||
.k-card--list::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 3px;
|
|
||||||
border-radius: 14px 14px 0 0;
|
|
||||||
}
|
|
||||||
.k-card--note::before {
|
|
||||||
right: 0;
|
|
||||||
background: linear-gradient(90deg, #7c3aed, #a78bfa);
|
|
||||||
}
|
|
||||||
.k-card--task::before {
|
|
||||||
width: 50%;
|
|
||||||
background: linear-gradient(90deg, #d4a017, transparent);
|
|
||||||
}
|
|
||||||
.k-card--list::before {
|
|
||||||
right: 0;
|
|
||||||
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Corner accents for entity types */
|
|
||||||
.k-card--person::after,
|
|
||||||
.k-card--place::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 0 14px 0 60px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
|
|
||||||
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update card hover to violet bloom**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```css
|
|
||||||
.k-card:hover {
|
|
||||||
border-color: rgba(255,255,255,0.14);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.k-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.15), 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
border-color: rgba(124, 58, 237, 0.2);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add sidebar section dividers**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```css
|
|
||||||
.filter-section { margin-bottom: 20px; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.filter-section { margin-bottom: 20px; }
|
|
||||||
.filter-section + .filter-section::before {
|
|
||||||
content: '· · ·';
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(124, 58, 237, 0.3);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
letter-spacing: 0.4em;
|
|
||||||
padding: 4px 0 12px;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add scroll fade to card grid**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```css
|
|
||||||
.card-grid {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px 20px;
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.card-grid {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px 20px;
|
|
||||||
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update task card dates to use amber**
|
|
||||||
|
|
||||||
Find in the task card CSS:
|
|
||||||
```css
|
|
||||||
.task-due {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.task-due {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-accent-warm);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Add Fraunces view title and update sidebar labels**
|
|
||||||
|
|
||||||
Find the filter panel label CSS:
|
|
||||||
```css
|
|
||||||
.filter-label {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--color-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.filter-label {
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--color-primary);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Update empty state with Fraunces narrator voice**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```html
|
|
||||||
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
|
|
||||||
<p>Nothing here yet.</p>
|
|
||||||
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">Try clearing the filters.</p>
|
|
||||||
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```html
|
|
||||||
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
|
|
||||||
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">No matches. Try clearing the filters.</p>
|
|
||||||
<p v-else class="empty-narrator">Your story is unwritten. Create your first note to begin.</p>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Add CSS:
|
|
||||||
```css
|
|
||||||
.empty-narrator {
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--color-accent-warm);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 8: Update card date stamps to amber**
|
|
||||||
|
|
||||||
Find:
|
|
||||||
```css
|
|
||||||
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 9: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 10: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/views/KnowledgeView.vue
|
|
||||||
git commit -m "feat(knowledge): card type DNA, violet hover bloom, amber timestamps, narrator empty states"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: ChatPanel + BriefingView + CalendarView — empty states + glow buttons
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/components/ChatPanel.vue`
|
|
||||||
- Modify: `frontend/src/views/BriefingView.vue`
|
|
||||||
- Modify: `frontend/src/views/CalendarView.vue`
|
|
||||||
- Modify: `frontend/src/App.vue`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update ChatPanel empty state**
|
|
||||||
|
|
||||||
In `frontend/src/components/ChatPanel.vue`, find:
|
|
||||||
```html
|
|
||||||
>Send a message to start the conversation.</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```html
|
|
||||||
>Start a conversation.</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
Find the `.empty-msg` CSS:
|
|
||||||
```css
|
|
||||||
.empty-msg {
|
|
||||||
```
|
|
||||||
|
|
||||||
Add these properties (find the existing block and add to it):
|
|
||||||
```css
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--color-accent-warm, #d4a017);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add scroll fade to ChatPanel messages**
|
|
||||||
|
|
||||||
Find the `.messages-container` CSS in ChatPanel.vue. Add:
|
|
||||||
```css
|
|
||||||
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update CalendarView empty state**
|
|
||||||
|
|
||||||
In `frontend/src/views/CalendarView.vue`, find:
|
|
||||||
```html
|
|
||||||
return ListView(
|
|
||||||
children: const [
|
|
||||||
SizedBox(height: 80),
|
|
||||||
Center(child: Text('No events')),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait — that's the Flutter file. In the web CalendarView, there's no dedicated empty state text to update since it's a FullCalendar component. Skip this for the web CalendarView — it doesn't have a custom empty state.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add glow to primary action buttons in App.vue global styles**
|
|
||||||
|
|
||||||
In `frontend/src/App.vue`, find the `<style>` block (the global unscoped one). The `btn-send` styles are in `ChatInputBar.vue` which is scoped. Instead, add a global hover glow rule. Find the existing `.app-footer` style and add after it:
|
|
||||||
|
|
||||||
No — the glow should be on the specific button components. The `btn-send` in `ChatInputBar.vue` already has a hover shadow. Let me update it there.
|
|
||||||
|
|
||||||
In `frontend/src/components/ChatInputBar.vue`, find:
|
|
||||||
```css
|
|
||||||
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update KnowledgeView new-note button glow**
|
|
||||||
|
|
||||||
In `frontend/src/views/KnowledgeView.vue`, find:
|
|
||||||
```css
|
|
||||||
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
```css
|
|
||||||
.btn-new-note:hover { background: rgba(124, 58, 237, 0.2); box-shadow: 0 0 12px rgba(124, 58, 237, 0.25); }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Update any remaining hardcoded indigo references in KnowledgeView**
|
|
||||||
|
|
||||||
Search for `99, 102, 241` in KnowledgeView.vue and replace with `124, 58, 237`. This covers all the rgba references in filter buttons, borders, today bar chips, etc.
|
|
||||||
|
|
||||||
Use find-and-replace across the file: `99, 102, 241` → `124, 58, 237`
|
|
||||||
|
|
||||||
- [ ] **Step 7: Update hardcoded indigo in BriefingView**
|
|
||||||
|
|
||||||
Search for `99, 102, 241` in BriefingView.vue and replace with `124, 58, 237`.
|
|
||||||
|
|
||||||
Search for `6366f1` in BriefingView.vue and replace with `7c3aed`.
|
|
||||||
|
|
||||||
- [ ] **Step 8: Update hardcoded indigo in AppHeader**
|
|
||||||
|
|
||||||
Search for `99, 102, 241` in AppHeader.vue and replace with `124, 58, 237` (for any remaining references not covered by Task 2).
|
|
||||||
|
|
||||||
- [ ] **Step 9: Update hardcoded indigo in App.vue shortcuts overlay**
|
|
||||||
|
|
||||||
Search for `99, 102, 241` in App.vue and replace with `124, 58, 237`.
|
|
||||||
|
|
||||||
Search for `6366f1` in App.vue and replace with `7c3aed`.
|
|
||||||
|
|
||||||
- [ ] **Step 10: Verify TypeScript compiles**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend && npx tsc --noEmit
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 11: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/src/components/ChatPanel.vue frontend/src/components/ChatInputBar.vue \
|
|
||||||
frontend/src/views/KnowledgeView.vue frontend/src/views/BriefingView.vue \
|
|
||||||
frontend/src/components/AppHeader.vue frontend/src/App.vue
|
|
||||||
git commit -m "feat: narrator empty states, scroll fades, glow buttons, violet color sweep"
|
|
||||||
```
|
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
# Unified Lookup Tool & Wikipedia Integration — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Replace `search_web` with a unified `lookup` tool (Wikipedia-first, SearXNG fallback) and add Wikipedia as a source in the research pipeline.
|
|
||||||
|
|
||||||
**Architecture:** New `wikipedia.py` service with `wiki_summary` and `wiki_search`. `lookup` tool in `web.py` replaces `search_web`. Research pipeline in `research.py` gains Wikipedia sources alongside SearXNG. All `search_web` references across the codebase are updated.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.12, httpx, pytest, asyncio
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Wikipedia Service Module
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/fabledassistant/services/wikipedia.py`
|
|
||||||
- Create: `tests/test_wikipedia.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests for `wiki_summary`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_wikipedia.py
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, patch, MagicMock
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wiki_summary_returns_extract():
|
|
||||||
from fabledassistant.services.wikipedia import wiki_summary
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"type": "standard",
|
|
||||||
"title": "Python (programming language)",
|
|
||||||
"extract": "Python is a high-level programming language.",
|
|
||||||
"content_urls": {
|
|
||||||
"desktop": {"page": "https://en.wikipedia.org/wiki/Python_(programming_language)"}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_client.get = AsyncMock(return_value=mock_response)
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
result = await wiki_summary("Python programming language")
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result["title"] == "Python (programming language)"
|
|
||||||
assert "high-level" in result["extract"]
|
|
||||||
assert "wikipedia.org" in result["url"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wiki_summary_returns_none_on_404():
|
|
||||||
from fabledassistant.services.wikipedia import wiki_summary
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_client.get = AsyncMock(side_effect=httpx.HTTPStatusError(
|
|
||||||
"Not Found", request=MagicMock(), response=MagicMock(status_code=404)
|
|
||||||
))
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
result = await wiki_summary("xyznonexistenttopic123")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wiki_summary_returns_none_on_disambiguation():
|
|
||||||
from fabledassistant.services.wikipedia import wiki_summary
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"type": "disambiguation",
|
|
||||||
"title": "Python",
|
|
||||||
"extract": "Python may refer to...",
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_client.get = AsyncMock(return_value=mock_response)
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
result = await wiki_summary("Python")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
|
||||||
Expected: FAIL with `ModuleNotFoundError: No module named 'fabledassistant.services.wikipedia'`
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `wiki_summary`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# src/fabledassistant/services/wikipedia.py
|
|
||||||
"""Wikipedia API: lightweight topic lookups and article search."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from urllib.parse import quote as url_quote
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_SUMMARY_URL = "https://en.wikipedia.org/api/rest_v1/page/summary"
|
|
||||||
_SEARCH_URL = "https://en.wikipedia.org/w/api.php"
|
|
||||||
_TIMEOUT = 5.0
|
|
||||||
_USER_AGENT = "FabledAssistant/1.0 (https://fabledsword.com)"
|
|
||||||
|
|
||||||
|
|
||||||
async def wiki_summary(query: str) -> dict | None:
|
|
||||||
"""Look up a topic by title via the Wikipedia REST summary endpoint.
|
|
||||||
|
|
||||||
Returns {"title", "extract", "url"} on hit, None on miss.
|
|
||||||
"""
|
|
||||||
encoded = url_quote(query.replace(" ", "_"), safe="")
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
timeout=_TIMEOUT, headers={"User-Agent": _USER_AGENT}
|
|
||||||
) as client:
|
|
||||||
resp = await client.get(f"{_SUMMARY_URL}/{encoded}", follow_redirects=True)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Wikipedia summary lookup failed for %r", query, exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if data.get("type") == "disambiguation":
|
|
||||||
return None
|
|
||||||
|
|
||||||
extract = data.get("extract", "").strip()
|
|
||||||
if not extract:
|
|
||||||
return None
|
|
||||||
|
|
||||||
url = (
|
|
||||||
data.get("content_urls", {}).get("desktop", {}).get("page")
|
|
||||||
or f"https://en.wikipedia.org/wiki/{encoded}"
|
|
||||||
)
|
|
||||||
return {"title": data.get("title", query), "extract": extract, "url": url}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
|
||||||
Expected: 3 passed
|
|
||||||
|
|
||||||
- [ ] **Step 5: Write failing tests for `wiki_search`**
|
|
||||||
|
|
||||||
Add to `tests/test_wikipedia.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wiki_search_returns_results():
|
|
||||||
from fabledassistant.services.wikipedia import wiki_search
|
|
||||||
|
|
||||||
search_response = MagicMock()
|
|
||||||
search_response.status_code = 200
|
|
||||||
search_response.json.return_value = {
|
|
||||||
"query": {
|
|
||||||
"search": [
|
|
||||||
{"title": "QUIC"},
|
|
||||||
{"title": "HTTP/3"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
search_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
summary_response = MagicMock()
|
|
||||||
summary_response.status_code = 200
|
|
||||||
summary_response.json.return_value = {
|
|
||||||
"type": "standard",
|
|
||||||
"title": "QUIC",
|
|
||||||
"extract": "QUIC is a transport layer protocol.",
|
|
||||||
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/QUIC"}},
|
|
||||||
}
|
|
||||||
summary_response.raise_for_status = MagicMock()
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_client.get = AsyncMock(side_effect=[search_response, summary_response, summary_response])
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
results = await wiki_search("QUIC protocol", limit=2)
|
|
||||||
|
|
||||||
assert len(results) >= 1
|
|
||||||
assert results[0]["title"] == "QUIC"
|
|
||||||
assert "transport" in results[0]["extract"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_wiki_search_returns_empty_on_failure():
|
|
||||||
from fabledassistant.services.wikipedia import wiki_search
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
|
|
||||||
mock_client = AsyncMock()
|
|
||||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection failed"))
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
results = await wiki_search("anything")
|
|
||||||
|
|
||||||
assert results == []
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Implement `wiki_search`**
|
|
||||||
|
|
||||||
Add to `src/fabledassistant/services/wikipedia.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def wiki_search(query: str, limit: int = 3) -> list[dict]:
|
|
||||||
"""Search Wikipedia for articles matching a query.
|
|
||||||
|
|
||||||
Returns [{"title", "extract", "url"}, ...] (may be empty).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
timeout=_TIMEOUT, headers={"User-Agent": _USER_AGENT}
|
|
||||||
) as client:
|
|
||||||
resp = await client.get(_SEARCH_URL, params={
|
|
||||||
"action": "query",
|
|
||||||
"list": "search",
|
|
||||||
"srsearch": query,
|
|
||||||
"srlimit": str(limit),
|
|
||||||
"format": "json",
|
|
||||||
})
|
|
||||||
resp.raise_for_status()
|
|
||||||
hits = resp.json().get("query", {}).get("search", [])
|
|
||||||
if not hits:
|
|
||||||
return []
|
|
||||||
|
|
||||||
results: list[dict] = []
|
|
||||||
for hit in hits:
|
|
||||||
title = hit.get("title", "")
|
|
||||||
if not title:
|
|
||||||
continue
|
|
||||||
encoded = url_quote(title.replace(" ", "_"), safe="")
|
|
||||||
try:
|
|
||||||
summary_resp = await client.get(
|
|
||||||
f"{_SUMMARY_URL}/{encoded}", follow_redirects=True,
|
|
||||||
)
|
|
||||||
summary_resp.raise_for_status()
|
|
||||||
data = summary_resp.json()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if data.get("type") == "disambiguation":
|
|
||||||
continue
|
|
||||||
extract = data.get("extract", "").strip()
|
|
||||||
if not extract:
|
|
||||||
continue
|
|
||||||
url = (
|
|
||||||
data.get("content_urls", {}).get("desktop", {}).get("page")
|
|
||||||
or f"https://en.wikipedia.org/wiki/{encoded}"
|
|
||||||
)
|
|
||||||
results.append({"title": data.get("title", title), "extract": extract, "url": url})
|
|
||||||
return results
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Wikipedia search failed for %r", query, exc_info=True)
|
|
||||||
return []
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Run all wikipedia tests**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_wikipedia.py -v`
|
|
||||||
Expected: 5 passed
|
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/wikipedia.py tests/test_wikipedia.py
|
|
||||||
git commit -m "feat: add wikipedia service with summary lookup and search"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Lookup Tool (replaces search_web)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/tools/web.py`
|
|
||||||
- Create: `tests/test_lookup_tool.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests for `lookup`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# tests/test_lookup_tool.py
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import AsyncMock, patch, MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lookup_wikipedia_hit():
|
|
||||||
"""lookup returns wikipedia source when wiki_summary succeeds."""
|
|
||||||
wiki_data = {
|
|
||||||
"title": "QUIC",
|
|
||||||
"extract": "QUIC is a transport layer protocol.",
|
|
||||||
"url": "https://en.wikipedia.org/wiki/QUIC",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=wiki_data):
|
|
||||||
from fabledassistant.services.tools.web import lookup_tool
|
|
||||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
|
||||||
|
|
||||||
assert result["success"] is True
|
|
||||||
assert result["type"] == "lookup"
|
|
||||||
assert result["source"] == "wikipedia"
|
|
||||||
assert result["data"]["title"] == "QUIC"
|
|
||||||
assert "transport" in result["data"]["extract"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lookup_wikipedia_miss_searxng_fallback():
|
|
||||||
"""lookup falls back to SearXNG + article fetch when Wikipedia misses."""
|
|
||||||
searxng_results = [
|
|
||||||
{"url": "https://example.com/quic", "title": "QUIC Explained", "snippet": "An overview..."},
|
|
||||||
]
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
|
||||||
patch("fabledassistant.services.tools.web.Config") as mock_config, \
|
|
||||||
patch("fabledassistant.services.tools.web._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
|
|
||||||
patch("fabledassistant.services.tools.web._fetch_full_article", new_callable=AsyncMock, return_value="Full article about QUIC..."):
|
|
||||||
mock_config.searxng_enabled.return_value = True
|
|
||||||
from fabledassistant.services.tools.web import lookup_tool
|
|
||||||
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
|
|
||||||
|
|
||||||
assert result["success"] is True
|
|
||||||
assert result["type"] == "lookup"
|
|
||||||
assert result["source"] == "web"
|
|
||||||
assert result["data"]["results"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lookup_wikipedia_miss_no_searxng():
|
|
||||||
"""lookup returns no-results when Wikipedia misses and SearXNG is not configured."""
|
|
||||||
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=None), \
|
|
||||||
patch("fabledassistant.services.tools.web.Config") as mock_config:
|
|
||||||
mock_config.searxng_enabled.return_value = False
|
|
||||||
from fabledassistant.services.tools.web import lookup_tool
|
|
||||||
result = await lookup_tool(user_id=1, arguments={"query": "xyznonexistent"})
|
|
||||||
|
|
||||||
assert result["success"] is True
|
|
||||||
assert result["source"] == "none"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lookup_always_available():
|
|
||||||
"""lookup tool must appear in get_tools_for_user regardless of SearXNG config."""
|
|
||||||
with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock, return_value=False), \
|
|
||||||
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="false"):
|
|
||||||
from fabledassistant.services.tools import get_tools_for_user
|
|
||||||
tools = await get_tools_for_user(user_id=1)
|
|
||||||
tool_names = {t["function"]["name"] for t in tools}
|
|
||||||
assert "lookup" in tool_names
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_lookup_tool.py -v`
|
|
||||||
Expected: FAIL (no `lookup_tool` function)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Replace `search_web` with `lookup` in `web.py`**
|
|
||||||
|
|
||||||
Replace the `search_web_tool` function (lines 12–36 of `src/fabledassistant/services/tools/web.py`) with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@tool(
|
|
||||||
name="lookup",
|
|
||||||
description=(
|
|
||||||
"Look up a topic, concept, or factual question. Returns a concise answer from "
|
|
||||||
"Wikipedia or web sources. Use for definitions, explanations, 'what is X', "
|
|
||||||
"'how does Y work', current events, or version numbers. No note is saved. "
|
|
||||||
"For comprehensive written reports saved as notes, use research_topic instead."
|
|
||||||
),
|
|
||||||
parameters={
|
|
||||||
"query": {"type": "string", "description": "The topic or question to look up"},
|
|
||||||
},
|
|
||||||
required=["query"],
|
|
||||||
)
|
|
||||||
async def lookup_tool(*, user_id, arguments, **_ctx):
|
|
||||||
from fabledassistant.config import Config
|
|
||||||
from fabledassistant.services.wikipedia import wiki_summary
|
|
||||||
|
|
||||||
query = arguments.get("query", "")
|
|
||||||
|
|
||||||
# 1. Try Wikipedia first
|
|
||||||
wiki = await wiki_summary(query)
|
|
||||||
if wiki:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"type": "lookup",
|
|
||||||
"source": "wikipedia",
|
|
||||||
"data": wiki,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Fall back to SearXNG + article fetch
|
|
||||||
if Config.searxng_enabled():
|
|
||||||
from fabledassistant.services.research import _search_searxng
|
|
||||||
from fabledassistant.services.rss import _fetch_full_article
|
|
||||||
|
|
||||||
results = await _search_searxng(query)
|
|
||||||
if results:
|
|
||||||
articles: list[dict] = []
|
|
||||||
for r in results[:2]:
|
|
||||||
url = r.get("url", "")
|
|
||||||
if not url:
|
|
||||||
continue
|
|
||||||
content = await _fetch_full_article(url)
|
|
||||||
articles.append({
|
|
||||||
"url": url,
|
|
||||||
"title": r.get("title", url),
|
|
||||||
"snippet": r.get("snippet", ""),
|
|
||||||
"content": (content or "")[:4000],
|
|
||||||
})
|
|
||||||
if articles:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"type": "lookup",
|
|
||||||
"source": "web",
|
|
||||||
"data": {"query": query, "results": articles},
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. No sources available
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"type": "lookup",
|
|
||||||
"source": "none",
|
|
||||||
"data": {
|
|
||||||
"query": query,
|
|
||||||
"message": "No results found. You can answer from your own knowledge.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run lookup tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_lookup_tool.py -v`
|
|
||||||
Expected: 4 passed
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/tools/web.py tests/test_lookup_tool.py
|
|
||||||
git commit -m "feat: replace search_web with unified lookup tool (Wikipedia + SearXNG fallback)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Update All `search_web` References
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/tools/web.py:45` (research_topic description)
|
|
||||||
- Modify: `src/fabledassistant/services/tools/web.py:67` (search_images description)
|
|
||||||
- Modify: `src/fabledassistant/services/tools/rss.py:75` (read_article description)
|
|
||||||
- Modify: `src/fabledassistant/services/generation_task.py:133` (status label map)
|
|
||||||
- Modify: `src/fabledassistant/services/llm.py:608` (action list)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update `research_topic` description**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/tools/web.py`, change the `research_topic` description from:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"For a quick factual answer without saving a note, use search_web."
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"For a quick factual answer without saving a note, use lookup."
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update `search_images` description**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/tools/web.py`, change the `search_images` description from:
|
|
||||||
|
|
||||||
```python
|
|
||||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use search_web for those.",
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use lookup for those.",
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update `read_article` description**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/tools/rss.py`, change:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"Do NOT use search_web for URLs — use this tool instead."
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"Do NOT use lookup for URLs — use this tool instead."
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update status label map in `generation_task.py`**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/generation_task.py`, line 133, change:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"search_web": "Searching the web",
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"lookup": "Looking up information",
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update action list in `llm.py`**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/llm.py`, line 608, change:
|
|
||||||
|
|
||||||
```python
|
|
||||||
actions.extend(["search_web", "research_topic", "search_images"])
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```python
|
|
||||||
actions.extend(["lookup", "research_topic", "search_images"])
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run full test suite to check for regressions**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/ -v`
|
|
||||||
Expected: All tests pass (no test references `search_web` by name in assertions)
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/tools/web.py src/fabledassistant/services/tools/rss.py src/fabledassistant/services/generation_task.py src/fabledassistant/services/llm.py
|
|
||||||
git commit -m "refactor: update all search_web references to lookup"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Add Wikipedia Sources to Research Pipeline
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/fabledassistant/services/research.py`
|
|
||||||
- Modify: `tests/test_research_pipeline.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test for Wikipedia in research pipeline**
|
|
||||||
|
|
||||||
Add to `tests/test_research_pipeline.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pipeline_includes_wikipedia_sources():
|
|
||||||
"""run_research_pipeline should merge Wikipedia results into the source pool."""
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
wiki_results = [{"title": "Wiki Article", "extract": "Wikipedia content about the topic.", "url": "https://en.wikipedia.org/wiki/Topic"}]
|
|
||||||
|
|
||||||
outline = [
|
|
||||||
{"title": "Section A", "focus": "Focus A"},
|
|
||||||
{"title": "Section B", "focus": "Focus B"},
|
|
||||||
]
|
|
||||||
|
|
||||||
note_id_counter = iter(range(30, 40))
|
|
||||||
|
|
||||||
def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
|
|
||||||
n = MagicMock()
|
|
||||||
n.id = next(note_id_counter)
|
|
||||||
n.title = title
|
|
||||||
return n
|
|
||||||
|
|
||||||
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
|
|
||||||
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
|
|
||||||
patch("fabledassistant.services.research.wiki_search", new_callable=AsyncMock, return_value=wiki_results), \
|
|
||||||
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
|
|
||||||
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline) as mock_outline, \
|
|
||||||
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
|
|
||||||
patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Summary."), \
|
|
||||||
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
|
|
||||||
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock):
|
|
||||||
|
|
||||||
from fabledassistant.services.research import run_research_pipeline
|
|
||||||
await run_research_pipeline("test topic", user_id=1, model="test-model")
|
|
||||||
|
|
||||||
# The sources passed to _generate_outline should include the Wikipedia article
|
|
||||||
sources_arg = mock_outline.call_args[0][1] # second positional arg
|
|
||||||
source_urls = [s["url"] for s in sources_arg]
|
|
||||||
assert "https://en.wikipedia.org/wiki/Topic" in source_urls
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_research_pipeline.py::test_pipeline_includes_wikipedia_sources -v`
|
|
||||||
Expected: FAIL (no `wiki_search` import in research.py)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add Wikipedia sources to the research pipeline**
|
|
||||||
|
|
||||||
In `src/fabledassistant/services/research.py`, add the import at the top (after existing imports):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fabledassistant.services.wikipedia import wiki_search
|
|
||||||
```
|
|
||||||
|
|
||||||
Then modify Step 2 (the parallel search section, around lines 208–246). Replace:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Step 2: Search all queries in parallel (200 ms stagger to avoid hammering SearXNG)
|
|
||||||
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
|
||||||
if i > 0:
|
|
||||||
await asyncio.sleep(0.2 * i)
|
|
||||||
_status(f"Searching: {query}...")
|
|
||||||
results = await _search_searxng(query)
|
|
||||||
logger.info("Research: query '%s' → %d results", query, len(results))
|
|
||||||
return query, results
|
|
||||||
|
|
||||||
search_results = await asyncio.gather(
|
|
||||||
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Deduplicate URLs across all queries
|
|
||||||
seen_urls: set[str] = set()
|
|
||||||
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
|
|
||||||
for query, results in search_results:
|
|
||||||
for result in results[:PAGES_PER_QUERY]:
|
|
||||||
url = result.get("url", "")
|
|
||||||
if url and url not in seen_urls:
|
|
||||||
seen_urls.add(url)
|
|
||||||
url_tasks.append((url, result, query))
|
|
||||||
|
|
||||||
# Fetch all unique URLs in parallel
|
|
||||||
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
|
||||||
title = result.get("title", url)
|
|
||||||
_status(f"Reading: {title[:60]}...")
|
|
||||||
content = await fetch_url_content(url)
|
|
||||||
return {
|
|
||||||
"url": url,
|
|
||||||
"title": title,
|
|
||||||
"query": query,
|
|
||||||
"snippet": result.get("snippet", ""),
|
|
||||||
"content": content,
|
|
||||||
}
|
|
||||||
|
|
||||||
all_sources: list[dict] = list(await asyncio.gather(
|
|
||||||
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
|
|
||||||
))
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Step 2: Search all queries in parallel (SearXNG + Wikipedia)
|
|
||||||
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
|
||||||
if i > 0:
|
|
||||||
await asyncio.sleep(0.2 * i)
|
|
||||||
_status(f"Searching: {query}...")
|
|
||||||
results = await _search_searxng(query)
|
|
||||||
logger.info("Research: query '%s' → %d results", query, len(results))
|
|
||||||
return query, results
|
|
||||||
|
|
||||||
async def _wiki_for_query(query: str) -> list[dict]:
|
|
||||||
return await wiki_search(query, limit=1)
|
|
||||||
|
|
||||||
searxng_task = asyncio.gather(
|
|
||||||
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
|
|
||||||
)
|
|
||||||
wiki_task = asyncio.gather(
|
|
||||||
*[_wiki_for_query(q) for q in queries]
|
|
||||||
)
|
|
||||||
search_results, wiki_results = await asyncio.gather(searxng_task, wiki_task)
|
|
||||||
|
|
||||||
# Deduplicate URLs across all queries
|
|
||||||
seen_urls: set[str] = set()
|
|
||||||
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
|
|
||||||
wiki_sources: list[dict] = [] # Wikipedia articles (already have content)
|
|
||||||
|
|
||||||
for query, results in search_results:
|
|
||||||
for result in results[:PAGES_PER_QUERY]:
|
|
||||||
url = result.get("url", "")
|
|
||||||
if url and url not in seen_urls:
|
|
||||||
seen_urls.add(url)
|
|
||||||
url_tasks.append((url, result, query))
|
|
||||||
|
|
||||||
# Add Wikipedia results (they already have content via extract)
|
|
||||||
for query, wiki_hits in zip(queries, wiki_results):
|
|
||||||
for hit in wiki_hits:
|
|
||||||
url = hit.get("url", "")
|
|
||||||
if url and url not in seen_urls:
|
|
||||||
seen_urls.add(url)
|
|
||||||
wiki_sources.append({
|
|
||||||
"url": url,
|
|
||||||
"title": hit["title"],
|
|
||||||
"query": query,
|
|
||||||
"snippet": hit["extract"][:200],
|
|
||||||
"content": hit["extract"],
|
|
||||||
})
|
|
||||||
|
|
||||||
# Fetch all unique SearXNG URLs in parallel
|
|
||||||
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
|
||||||
title = result.get("title", url)
|
|
||||||
_status(f"Reading: {title[:60]}...")
|
|
||||||
content = await fetch_url_content(url)
|
|
||||||
return {
|
|
||||||
"url": url,
|
|
||||||
"title": title,
|
|
||||||
"query": query,
|
|
||||||
"snippet": result.get("snippet", ""),
|
|
||||||
"content": content,
|
|
||||||
}
|
|
||||||
|
|
||||||
fetched_sources: list[dict] = list(await asyncio.gather(
|
|
||||||
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
|
|
||||||
))
|
|
||||||
|
|
||||||
all_sources = wiki_sources + fetched_sources
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the new test to verify it passes**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_research_pipeline.py::test_pipeline_includes_wikipedia_sources -v`
|
|
||||||
Expected: PASS
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run the full research test suite for regressions**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/test_research_pipeline.py -v`
|
|
||||||
Expected: All tests pass
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run full test suite**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/ -v`
|
|
||||||
Expected: All tests pass
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/fabledassistant/services/research.py tests/test_research_pipeline.py
|
|
||||||
git commit -m "feat: add Wikipedia as research pipeline source alongside SearXNG"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Lint, Typecheck, Final Verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- All modified files
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run ruff lint**
|
|
||||||
|
|
||||||
Run: `uv run ruff check src/fabledassistant/services/wikipedia.py src/fabledassistant/services/tools/web.py src/fabledassistant/services/research.py tests/test_wikipedia.py tests/test_lookup_tool.py`
|
|
||||||
Expected: All checks passed (fix any issues if not)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run typecheck**
|
|
||||||
|
|
||||||
Run: `cd frontend && npx vue-tsc --noEmit` (frontend unchanged, but verify nothing broke)
|
|
||||||
Expected: Clean
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run full test suite one last time**
|
|
||||||
|
|
||||||
Run: `uv run pytest tests/ -v`
|
|
||||||
Expected: All tests pass
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit any lint fixes if needed**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -u
|
|
||||||
git commit -m "fix: lint cleanup for lookup/wikipedia changes"
|
|
||||||
```
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
# ChatPanel Unification Design
|
|
||||||
|
|
||||||
**Date:** 2026-04-03
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Replace the four divergent chat surfaces (ChatView, BriefingView, WorkspaceView, HomeView widget) with a single `ChatPanel` component that encapsulates all chat behaviour — streaming, TTS, PTT, tool calls, thinking blocks, abort — so that fixes and features automatically apply to every context.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Background
|
|
||||||
|
|
||||||
The app currently has four independent chat implementations that have drifted significantly:
|
|
||||||
|
|
||||||
| Surface | File | Gap |
|
|
||||||
|---|---|---|
|
|
||||||
| Main chat | `ChatView.vue` | Canonical reference |
|
|
||||||
| Briefing | `BriefingView.vue` | Had separate TTS impl (now fixed), no PTT, streaming race bug |
|
|
||||||
| Workspace | `WorkspaceView.vue` | TTS missing until recently, different input wiring |
|
|
||||||
| Dashboard widget | `HomeView.vue` + `DashboardChatInput.vue` | Separate input component, response rendered manually in parent, no TTS, no PTT |
|
|
||||||
|
|
||||||
Every fix to chat has required touching 3–4 files. This design makes chat a first-class component.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Component: `ChatPanel.vue`
|
|
||||||
|
|
||||||
A single Vue 3 component that owns the entire chat interaction loop for a given conversation context. Two variants controlled by a `variant` prop:
|
|
||||||
|
|
||||||
- **`full`** — full-height chat: message history, streaming bubble, input bar, all controls
|
|
||||||
- **`widget`** — compact embedded chat: input bar + compact response area, no history scroll
|
|
||||||
|
|
||||||
Both variants share identical internals: same composables, same store reads, same TTS/PTT/abort logic.
|
|
||||||
|
|
||||||
### Extracted Sub-components
|
|
||||||
|
|
||||||
| Component | Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| `ChatInputBar.vue` | Unified input bar: textarea, note picker, PTT mic, send button, abort button |
|
|
||||||
| `ChatMessageList.vue` | Scrollable message history with auto-scroll, bulk-select (full variant only) |
|
|
||||||
| `ChatStreamingBubble.vue` | Live streaming content display + thinking block |
|
|
||||||
| `ChatToolCallList.vue` | Tool call cards, collapsed/expanded state |
|
|
||||||
|
|
||||||
### State Ownership
|
|
||||||
|
|
||||||
`ChatPanel` reads from `useChatStore` directly — it does not accept messages or streaming state as props. This mirrors how all current views work and avoids prop-drilling re-implementation.
|
|
||||||
|
|
||||||
The conversation being displayed is controlled via a `convId` prop. When `convId` is undefined, `ChatPanel` uses `chatStore.currentConversationId`. The parent view sets up the conversation (creates it if needed) and passes the ID down.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Props & Emits Interface
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ChatPanelProps {
|
|
||||||
variant: 'full' | 'widget'
|
|
||||||
convId?: number // which conversation to display; undefined = store current
|
|
||||||
projectId?: number // workspace: pins RAG scope, passed to sendMessage
|
|
||||||
briefingMode?: boolean // briefing: hides RAG scope chip, enables briefing-specific send path
|
|
||||||
placeholder?: string // input placeholder text
|
|
||||||
autoFocus?: boolean // focus input on mount
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatPanelEmits {
|
|
||||||
// Emitted when a new conversation is started from the widget (so parent can track convId)
|
|
||||||
(e: 'conversation-started', convId: number): void
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
All other behaviour (TTS, PTT, thinking, tool calls, streaming indicator, abort) is always on — not gated by props. The intentional differences between views are expressed only through the props above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Variant Behaviour
|
|
||||||
|
|
||||||
### `variant="full"` (ChatView, BriefingView, WorkspaceView)
|
|
||||||
|
|
||||||
Layout (top to bottom):
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────────┐
|
|
||||||
│ [RAG scope chip / briefing header] │ ← shown unless briefingMode or projectId set
|
|
||||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
|
||||||
│ ChatMessageList │
|
|
||||||
│ user bubble │
|
|
||||||
│ assistant bubble + tool calls │
|
|
||||||
│ thinking block (always shown) │
|
|
||||||
│ ... │
|
|
||||||
│ ChatStreamingBubble (while streaming) │
|
|
||||||
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
|
||||||
│ ChatInputBar │
|
|
||||||
│ [textarea] [note-picker] [mic] [▶] │
|
|
||||||
│ [listen toggle] [abort] │
|
|
||||||
└────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### `variant="widget"` (HomeView dashboard)
|
|
||||||
|
|
||||||
Layout (top to bottom, compact):
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────────┐
|
|
||||||
│ ChatInputBar (pill style) │
|
|
||||||
│ [textarea] [mic] [▶] │
|
|
||||||
├────────────────────────────────────────┤
|
|
||||||
│ [query text] (after send) │
|
|
||||||
│ [streaming / final response text] │
|
|
||||||
│ [tool call chips] │
|
|
||||||
│ [Continue in Chat →] │
|
|
||||||
└────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
The widget variant does NOT show full message history. It shows only the most recent exchange. Once a new conversation is started or the user navigates to `/chat/:id`, the full history is available.
|
|
||||||
|
|
||||||
The `.dashboard-response` section currently in `HomeView.vue` moves inside `ChatPanel` and is rendered when `variant="widget"` and a conversation exists.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TTS / PTT Wiring
|
|
||||||
|
|
||||||
`ChatPanel` instantiates `useStreamingTts` and `useListenMode` internally. These are not passed as props.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Inside ChatPanel setup()
|
|
||||||
const listenMode = useListenMode()
|
|
||||||
const voiceTtsEnabled = computed(() => /* same check as current views */)
|
|
||||||
const tts = useStreamingTts({
|
|
||||||
streamingContent: computed(() => chatStore.streamingContent),
|
|
||||||
streaming: computed(() => !!chatStore.streaming),
|
|
||||||
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
PTT is handled inside `ChatInputBar` via the existing `useVoiceRecorder` composable (already used in `DashboardChatInput`). On recording stop, the transcribed text is placed in the textarea and auto-submitted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Per-View Migration
|
|
||||||
|
|
||||||
### ChatView → `<ChatPanel variant="full">`
|
|
||||||
- Remove: all TTS/PTT/streaming/abort logic, scroll management, input bar template
|
|
||||||
- Keep: route wiring, conversation list sidebar, bulk-delete UI (sidebar stays in ChatView)
|
|
||||||
- ChatPanel replaces only the right-hand panel
|
|
||||||
|
|
||||||
### BriefingView → `<ChatPanel variant="full" briefingMode />`
|
|
||||||
- Remove: streaming watch, TTS, manual scroll, input bar, response persistence workaround
|
|
||||||
- Keep: history dropdown (today / past briefings), date header
|
|
||||||
- `briefingMode` hides the RAG scope chip
|
|
||||||
|
|
||||||
### WorkspaceView → `<ChatPanel variant="full" :projectId="projectId">`
|
|
||||||
- Remove: inline chat input, streaming watch, TTS wiring
|
|
||||||
- Keep: 3-panel grid layout, task panel, note editor panel
|
|
||||||
- ChatPanel takes the centre column
|
|
||||||
|
|
||||||
### HomeView → `<ChatPanel variant="widget">`
|
|
||||||
- Remove: `DashboardChatInput` import + usage, `.dashboard-response` section, all manual store wiring (`dashboardConvId`, `dashboardQuery`, `dashboardFinalContent`, `dashboardFinalToolCalls`, `onChatSubmit`)
|
|
||||||
- Keep: dashboard layout, projects/tasks/events sections
|
|
||||||
- `DashboardChatInput.vue` deleted
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Parent view
|
|
||||||
└─ <ChatPanel :convId="convId" variant="full|widget">
|
|
||||||
├─ reads: useChatStore (messages, streaming, streamingContent, currentConversation)
|
|
||||||
├─ ChatMessageList — renders history from store
|
|
||||||
├─ ChatStreamingBubble — renders chatStore.streamingContent while streaming
|
|
||||||
├─ ChatToolCallList — renders tool calls from streaming + finalized messages
|
|
||||||
├─ ChatInputBar
|
|
||||||
│ ├─ usePtt (mic → textarea → auto-send)
|
|
||||||
│ └─ emits: submit(content, contextNoteId)
|
|
||||||
├─ useStreamingTts (sentence-chunk TTS during streaming)
|
|
||||||
└─ useListenMode (shared global toggle)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Created / Modified
|
|
||||||
|
|
||||||
**Created:**
|
|
||||||
- `frontend/src/components/ChatPanel.vue`
|
|
||||||
- `frontend/src/components/ChatInputBar.vue`
|
|
||||||
- `frontend/src/components/ChatMessageList.vue`
|
|
||||||
- `frontend/src/components/ChatStreamingBubble.vue`
|
|
||||||
- (no new composable needed — PTT uses existing `useVoiceRecorder.ts`)
|
|
||||||
|
|
||||||
**Modified:**
|
|
||||||
- `frontend/src/views/ChatView.vue` — use ChatPanel for the chat area
|
|
||||||
- `frontend/src/views/BriefingView.vue` — replace chat section with ChatPanel
|
|
||||||
- `frontend/src/views/WorkspaceView.vue` — replace inline chat with ChatPanel
|
|
||||||
- `frontend/src/views/HomeView.vue` — replace DashboardChatInput + response section with ChatPanel widget
|
|
||||||
|
|
||||||
**Deleted:**
|
|
||||||
- `frontend/src/components/DashboardChatInput.vue`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CSS / Styling
|
|
||||||
|
|
||||||
- `ChatPanel` carries its own scoped CSS for both variants
|
|
||||||
- `ChatInputBar` replicates the pill style currently in `DashboardChatInput` and the flat style in `ChatView` — variant is controlled by a `pill` boolean prop (default false; widget sets it true)
|
|
||||||
- All existing UI design language tokens (`--color-primary`, `--radius-lg`, Fraunces labels, gradient send button) are preserved
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Does NOT Change
|
|
||||||
|
|
||||||
- Chat store (`useChatStore`) — unchanged
|
|
||||||
- API client (`client.ts`) — unchanged
|
|
||||||
- Backend routes — unchanged
|
|
||||||
- WorkspaceTaskPanel and WorkspaceNoteEditor — unchanged
|
|
||||||
- Briefing history dropdown and date header — unchanged
|
|
||||||
- ChatView conversation sidebar and bulk-delete — unchanged
|
|
||||||
- RAG scope chip logic — moved inside ChatPanel, behaviour identical
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# Streaming TTS Design
|
|
||||||
|
|
||||||
**Date:** 2026-04-03
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Start playing TTS audio during LLM generation rather than waiting for the full response to finish. When listen mode is on, the first sentence plays as soon as Kokoro finishes synthesizing it — while the LLM is still streaming the rest of the response.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
Client-side sentence queuing composable. The frontend accumulates streaming tokens, detects sentence boundaries, fires per-sentence synthesis requests concurrently, and plays audio in strict insertion order. The existing `/api/voice/synthesise` backend endpoint is unchanged.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### `useStreamingTts` composable
|
|
||||||
|
|
||||||
**File:** `frontend/src/composables/useStreamingTts.ts`
|
|
||||||
|
|
||||||
**Inputs:**
|
|
||||||
- `streamingContent: Ref<string>` — the growing accumulated response text (e.g. `store.streamingContent`)
|
|
||||||
- `streaming: Ref<boolean>` — whether the LLM is currently generating
|
|
||||||
- `enabled: Ref<boolean>` — `true` when listen mode is on AND TTS is available
|
|
||||||
|
|
||||||
**Exports:**
|
|
||||||
- `speaking: Ref<boolean>` — `true` while any synthesis is in-flight or audio is playing
|
|
||||||
- `stop()` — cancels all pending synthesis/playback and clears the queue
|
|
||||||
|
|
||||||
**Internal state:**
|
|
||||||
- `sentenceBuffer: string` — accumulates characters since the last dispatched sentence
|
|
||||||
- `lastSeenLength: number` — tracks how far into `streamingContent` we've processed
|
|
||||||
- `abortId: number` — incremented on `stop()`; each queued promise checks the current id and bails if stale
|
|
||||||
- `playQueue: Promise<void>` — a chained promise that serializes audio playback in insertion order
|
|
||||||
|
|
||||||
**Sentence detection:**
|
|
||||||
- Regex: `/[.!?]+(?=\s|$)/` — handles `...`, `?!`, multi-punctuation
|
|
||||||
- Triggered on every `streamingContent` change and on `streaming` flipping `false` (flush)
|
|
||||||
- Fragments < 3 characters after markdown stripping are skipped
|
|
||||||
|
|
||||||
**Per-sentence pipeline:**
|
|
||||||
1. Strip markdown (same logic as current `speakLastAssistantMessage`)
|
|
||||||
2. Fire `synthesiseSpeech(sentence)` immediately — runs concurrently with other sentences
|
|
||||||
3. On failure: one immediate retry. If retry also fails, skip silently and advance the queue
|
|
||||||
4. Resolved blob is inserted into the playback queue at its original position
|
|
||||||
5. Playback queue plays blobs strictly in insertion order via `useVoiceAudio`
|
|
||||||
|
|
||||||
**Stream-end flush:**
|
|
||||||
- When `streaming` flips `false`, any remaining `sentenceBuffer` content (fragment without terminal punctuation) is dispatched as a final sentence — covers responses that end without a period
|
|
||||||
|
|
||||||
**Automatic reset:**
|
|
||||||
- When `streaming` flips `true` (new message starting), `stop()` is called automatically to cancel any in-flight audio from the previous response before starting fresh
|
|
||||||
|
|
||||||
### Views updated
|
|
||||||
|
|
||||||
| View | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `ChatView.vue` | Replace `speakLastAssistantMessage()` + `watch(streaming)` + `synthesising` ref with `useStreamingTts` |
|
|
||||||
| `BriefingView.vue` | Replace `speakText()` + `watch(streaming)` + `synthesising` ref with `useStreamingTts` |
|
|
||||||
| `WorkspaceView.vue` | Add listen mode toggle button (same UI pattern as ChatView) + `useStreamingTts` wired to workspace chat stream |
|
|
||||||
|
|
||||||
In all three views: the `speaking` export from `useStreamingTts` replaces the old `synthesising || audio.playing.value` checks for button busy state.
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
No changes. `/api/voice/synthesise` accepts shorter sentence-length strings without issue.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
| Scenario | Behavior |
|
|
||||||
|----------|----------|
|
|
||||||
| Synthesis fails for a sentence | One immediate retry; if retry fails, sentence is skipped, queue advances, and a `console.warn` is emitted with the sentence index and error |
|
|
||||||
| `stop()` called mid-queue | `abortId` incremented; all in-flight promises check id and discard their result |
|
|
||||||
| New message starts while audio playing | `watch(streaming, true → ...)` calls `stop()` before starting new queue |
|
|
||||||
| TTS unavailable or listen mode off | Composable is inert — watchers do nothing, no requests fired |
|
|
||||||
| Fragment < 3 chars after stripping | Skipped without a TTS request |
|
|
||||||
| Response ends without terminal punctuation | Remaining buffer flushed as final sentence on stream-end |
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
LLM SSE chunks → store.streamingContent (grows)
|
|
||||||
↓
|
|
||||||
useStreamingTts watcher
|
|
||||||
↓
|
|
||||||
sentenceBuffer accumulation
|
|
||||||
↓
|
|
||||||
sentence boundary detected? → synthesiseSpeech(sentence) [concurrent]
|
|
||||||
↓ ↓ (fail → 1 retry → skip)
|
|
||||||
playQueue.then(play blob) resolved blob
|
|
||||||
↓
|
|
||||||
useVoiceAudio.play() [sequential]
|
|
||||||
↓
|
|
||||||
audio output
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
- **New:** `frontend/src/composables/useStreamingTts.ts`
|
|
||||||
- **Modified:** `frontend/src/views/ChatView.vue` — swap TTS logic for composable
|
|
||||||
- **Modified:** `frontend/src/views/BriefingView.vue` — swap TTS logic for composable
|
|
||||||
- **Modified:** `frontend/src/views/WorkspaceView.vue` — add listen mode + composable
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
# Article Reading Design
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Allow the LLM to fetch and read the full text of any URL on demand, fix conversation history so tool context survives follow-up turns, and make the briefing Discuss button inject article content as a persisted tool exchange rather than raw user-message text.
|
|
||||||
|
|
||||||
**Architecture:** Four self-contained changes — history reconstruction fix (prerequisite), `read_article` tool, Discuss endpoint, and content cap removal.
|
|
||||||
|
|
||||||
**Tech Stack:** Python/Quart backend, trafilatura (already installed), SQLAlchemy async, Vue 3 frontend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem summary
|
|
||||||
|
|
||||||
Three interrelated issues observed in briefing conversations:
|
|
||||||
|
|
||||||
1. **Missing `read_article` tool** — when a user pastes a URL, the LLM calls `search_web` (a SearXNG text search), which returns generic site descriptions instead of article content.
|
|
||||||
2. **History reconstruction bug** — `routes/chat.py:166` builds the `history` list with only `role` + `content`, silently dropping all `tool_calls` and their results from prior turns. Tool context is lost on every follow-up.
|
|
||||||
3. **Discuss button UX** — inlines raw article text into the user message bubble. Feels clumsy, and the model sometimes searches notes on follow-ups anyway because the article isn't clearly marked as "loaded" context.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### 1. History reconstruction fix
|
|
||||||
**File:** `src/fabledassistant/routes/chat.py`
|
|
||||||
|
|
||||||
The loop at line ~164 that builds `history` must be updated to replay tool exchanges:
|
|
||||||
|
|
||||||
```python
|
|
||||||
history = []
|
|
||||||
for msg in conv.messages:
|
|
||||||
if msg.role == "system":
|
|
||||||
continue
|
|
||||||
msg_dict = {"role": msg.role, "content": msg.content or ""}
|
|
||||||
if msg.tool_calls:
|
|
||||||
msg_dict["tool_calls"] = [
|
|
||||||
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
|
|
||||||
for tc in msg.tool_calls
|
|
||||||
]
|
|
||||||
history.append(msg_dict)
|
|
||||||
for tc in msg.tool_calls:
|
|
||||||
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
|
|
||||||
else:
|
|
||||||
history.append(msg_dict)
|
|
||||||
```
|
|
||||||
|
|
||||||
The `tool_calls` JSONB column already stores `[{function, arguments, result}]` per call. No schema change needed.
|
|
||||||
|
|
||||||
### 2. `read_article` tool
|
|
||||||
**Files:** `src/fabledassistant/services/research.py`, `src/fabledassistant/services/tools.py`, `src/fabledassistant/services/rss.py`
|
|
||||||
|
|
||||||
Move `_fetch_full_article` from `rss.py` to `research.py` (imported back into `rss.py` to avoid breaking existing calls). This makes it available to `execute_tool` without a circular import.
|
|
||||||
|
|
||||||
Tool definition added to `_TOOLS` in `tools.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "read_article",
|
|
||||||
"description": (
|
|
||||||
"Fetch and read the full text of a web page or article from a URL. "
|
|
||||||
"Use when the user shares a URL and wants you to read it, "
|
|
||||||
"or to get the full content of a linked page. "
|
|
||||||
"Do not use search_web for URLs — use this tool instead."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"url": {"type": "string", "description": "The URL to fetch"}
|
|
||||||
},
|
|
||||||
"required": ["url"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`execute_tool` handler:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif tool_name == "read_article":
|
|
||||||
from fabledassistant.services.research import _fetch_full_article
|
|
||||||
url = arguments.get("url", "").strip()
|
|
||||||
if not url:
|
|
||||||
return {"success": False, "error": "No URL provided"}
|
|
||||||
content = await _fetch_full_article(url)
|
|
||||||
if not content:
|
|
||||||
return {"success": False, "error": f"Could not fetch article content from {url}"}
|
|
||||||
TOOL_CONTENT_CAP = 40_000
|
|
||||||
truncated = len(content) > TOOL_CONTENT_CAP
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"type": "article_content",
|
|
||||||
"url": url,
|
|
||||||
"content": content[:TOOL_CONTENT_CAP],
|
|
||||||
"truncated": truncated,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. `add_message` — add `tool_calls` parameter
|
|
||||||
**File:** `src/fabledassistant/services/chat.py`
|
|
||||||
|
|
||||||
`add_message` needs to accept and store `tool_calls` so the Discuss endpoint can create synthetic messages:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def add_message(
|
|
||||||
conversation_id: int,
|
|
||||||
role: str,
|
|
||||||
content: str,
|
|
||||||
context_note_id: int | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
tool_calls: list | None = None,
|
|
||||||
) -> Message:
|
|
||||||
```
|
|
||||||
|
|
||||||
Set `msg.tool_calls = tool_calls` when provided.
|
|
||||||
|
|
||||||
### 4. Discuss endpoint
|
|
||||||
**File:** `src/fabledassistant/routes/briefing.py`
|
|
||||||
|
|
||||||
New route: `POST /api/briefing/articles/<int:item_id>/discuss`
|
|
||||||
|
|
||||||
Request body: `{"conv_id": <int>}`
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Look up `rss_items` row by `item_id` — verify it belongs to the user via feed ownership. Return 404 if not found.
|
|
||||||
2. Look up conversation by `conv_id` — verify it belongs to the user. Return 404 if not found.
|
|
||||||
3. If generation already running for `conv_id` → return 409.
|
|
||||||
4. Fetch stored content: `article_content = item.content or item.snippet or ""`
|
|
||||||
5. Store synthetic assistant message (status=`"complete"`, role=`"assistant"`, content=`""`, tool_calls as below):
|
|
||||||
```python
|
|
||||||
synthetic_tool_calls = [{
|
|
||||||
"function": "read_article",
|
|
||||||
"arguments": {"url": item.url},
|
|
||||||
"result": {
|
|
||||||
"success": True,
|
|
||||||
"type": "article_content",
|
|
||||||
"url": item.url,
|
|
||||||
"content": article_content,
|
|
||||||
"truncated": False,
|
|
||||||
},
|
|
||||||
}]
|
|
||||||
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
|
||||||
```
|
|
||||||
6. Store user message: `await add_message(conv_id, "user", "Please summarize and discuss this article.")`
|
|
||||||
7. Build `history` from `conv.messages` (using the fixed builder above).
|
|
||||||
8. Create assistant placeholder, create buffer, launch `run_generation` as normal.
|
|
||||||
9. Return `{"assistant_message_id": ..., "status": "generating"}` 202.
|
|
||||||
|
|
||||||
### 5. Frontend: BriefingView.vue
|
|
||||||
**File:** `frontend/src/views/BriefingView.vue`
|
|
||||||
|
|
||||||
Replace `discussArticle()`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async function discussArticle(item: NewsItem) {
|
|
||||||
if (!todayConvId.value) return
|
|
||||||
if (!isToday.value) selectedConvId.value = todayConvId.value
|
|
||||||
await nextTick(() => {
|
|
||||||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
||||||
})
|
|
||||||
await apiClient.post(`/api/briefing/articles/${item.id}/discuss`, {
|
|
||||||
conv_id: todayConvId.value,
|
|
||||||
})
|
|
||||||
// Re-fetch conversation so the new messages appear, then start SSE streaming.
|
|
||||||
// The existing chatStore.fetchConversation + startStreaming pattern handles this.
|
|
||||||
await chatStore.fetchConversation(todayConvId.value)
|
|
||||||
chatStore.startStreaming(todayConvId.value)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The exact method names (`fetchConversation`, `startStreaming`) should match what `BriefingView.vue` already uses for the reply flow — confirm during implementation.
|
|
||||||
|
|
||||||
The article no longer appears as wall-of-text in the user bubble. The chat UI shows it as a `read_article` tool call card (already handled by `ToolCallCard.vue`).
|
|
||||||
|
|
||||||
### 6. Content cap removal
|
|
||||||
**File:** `src/fabledassistant/services/rss.py`
|
|
||||||
|
|
||||||
Remove `[:CONTENT_MAX_CHARS]` from:
|
|
||||||
- `content = _html_to_text(content)[:CONTENT_MAX_CHARS]` in `extract_item()`
|
|
||||||
- `item.content = full_text[:CONTENT_MAX_CHARS]` in the enrichment task
|
|
||||||
|
|
||||||
The `CONTENT_MAX_CHARS` constant can be removed entirely. Trafilatura extracts only article body text (typically 2K–15K chars for news articles), so content is naturally bounded.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
### User pastes a URL in chat
|
|
||||||
1. User sends message with a URL
|
|
||||||
2. LLM calls `read_article(url)`
|
|
||||||
3. `execute_tool` calls `_fetch_full_article(url)` → trafilatura extracts clean text
|
|
||||||
4. Tool result appended in-memory as `{role: "tool", content: json}`
|
|
||||||
5. LLM responds based on article content
|
|
||||||
6. Generation saves assistant message with `tool_calls=[{function:"read_article", arguments, result}]`
|
|
||||||
7. Follow-up turns: history builder replays tool_call + tool result → article stays in context
|
|
||||||
|
|
||||||
### User clicks Discuss on a briefing article
|
|
||||||
1. Frontend calls `POST /api/briefing/articles/{item_id}/discuss` with `{conv_id}`
|
|
||||||
2. Backend fetches stored article text from DB (no network request)
|
|
||||||
3. Backend stores synthetic assistant message with `read_article` tool result
|
|
||||||
4. Backend stores user message `"Please summarize and discuss this article."`
|
|
||||||
5. Generation runs — LLM sees pre-loaded article in history
|
|
||||||
6. Follow-ups retain context via fixed history builder
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
| Scenario | Behaviour |
|
|
||||||
|---|---|
|
|
||||||
| `_fetch_full_article` returns `None` (network/extraction failure) | Tool returns `{success: False, error: "Could not fetch article content from [url]"}` — LLM reports conversationally |
|
|
||||||
| Discuss: `item_id` not found or wrong user | 404 |
|
|
||||||
| Discuss: `conv_id` not found or wrong user | 404 |
|
|
||||||
| Discuss: article has no stored content | Falls back to empty string — LLM works with what it has |
|
|
||||||
| Discuss: generation already running | 409 |
|
|
||||||
| Messages with `tool_calls = None` | History builder unchanged — no regression for existing conversations |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- **Unit:** `_fetch_full_article` returns `None` → `read_article` tool result has `success: False`
|
|
||||||
- **Unit:** History builder with a stored message that has `tool_calls` → output includes assistant tool_call dict + a `{role: "tool"}` dict
|
|
||||||
- **Unit:** History builder with messages where `tool_calls = None` → output unchanged from current behaviour
|
|
||||||
- **Integration:** `POST /api/briefing/articles/{item_id}/discuss` → two messages stored (synthetic assistant + user message), generation triggered, returns 202
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# Research Pipeline — Multi-Note Redesign
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Replace the single monolithic research note with a set of focused, topic-driven notes plus an index note that links them — making research output browsable, TTS-friendly, and well-organized.
|
|
||||||
|
|
||||||
**Architecture:** Two new LLM calls (outline generation + N parallel section syntheses) replace the single large synthesis call. Public API unchanged — callers receive the index note. Fallback to single-note behavior on any outline failure.
|
|
||||||
|
|
||||||
**Tech Stack:** Python/Quart backend, existing `research.py` service, asyncio.gather for parallelism.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The current pipeline synthesizes one note with a minimum of 2500 words and 6 sections. This creates:
|
|
||||||
- Notes too large to read or listen to comfortably
|
|
||||||
- No way to navigate directly to a specific sub-topic
|
|
||||||
- TTS failures on long prose (8000-char route limit, unbounded sentence buffers)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pipeline Flow
|
|
||||||
|
|
||||||
Public signature unchanged:
|
|
||||||
```python
|
|
||||||
async def run_research_pipeline(
|
|
||||||
topic: str,
|
|
||||||
user_id: int,
|
|
||||||
model: str,
|
|
||||||
buf=None,
|
|
||||||
project_id: int | None = None,
|
|
||||||
) -> Note: # returns the index note
|
|
||||||
```
|
|
||||||
|
|
||||||
Execution order:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Generate sub-queries (unchanged)
|
|
||||||
2. Search + fetch sources (unchanged)
|
|
||||||
3. Generate topic outline (NEW — one LLM call → 3–7 section dicts)
|
|
||||||
4. Synthesize each section note (NEW — parallelized via asyncio.gather)
|
|
||||||
5. Create all section notes in DB (sequential, tagged ["research"], same project_id)
|
|
||||||
6. Create index note (NEW — links all sections)
|
|
||||||
7. Return index note
|
|
||||||
```
|
|
||||||
|
|
||||||
Status messages via `buf.append_event("status", ...)`:
|
|
||||||
- `"Generating outline…"`
|
|
||||||
- `"Writing: [Section Title]…"` (one per section, emitted before synthesis starts)
|
|
||||||
- `"Saving [N] notes…"`
|
|
||||||
|
|
||||||
No note content is streamed into chat. After the tool call resolves, the LLM writes a brief conversational summary citing the index note title and section count.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Outline Generation
|
|
||||||
|
|
||||||
New function: `_generate_outline(topic, sources, model) -> list[dict]`
|
|
||||||
|
|
||||||
Sends all fetched sources to the model with a prompt requesting a JSON array:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{"title": "Quantum Entanglement: Mechanisms", "focus": "How entanglement works at the physical level"},
|
|
||||||
{"title": "Quantum Computing Hardware", "focus": "Ion traps, superconducting qubits, photonic approaches"}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prompt requirements:**
|
|
||||||
- Produce 3–7 sections covering distinct aspects of the topic
|
|
||||||
- Titles must work as standalone note titles (no "Overview" or "Introduction" generics)
|
|
||||||
- No overlap between sections
|
|
||||||
- `focus` is one sentence describing what this section should specifically cover
|
|
||||||
|
|
||||||
**Guardrails:**
|
|
||||||
- Fewer than 3 sections parsed → fall back to single-note synthesis
|
|
||||||
- JSON parse failure → fall back to single-note synthesis
|
|
||||||
- More than 8 sections → truncate to 8
|
|
||||||
|
|
||||||
**Model params:** `max_tokens=400, num_ctx=16384` (outline is short)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section Synthesis
|
|
||||||
|
|
||||||
New function: `_synthesize_section(section_title, section_focus, sources, model) -> tuple[str, str]`
|
|
||||||
|
|
||||||
Returns `(title, body_markdown)`.
|
|
||||||
|
|
||||||
All sections receive all fetched sources. The `section_focus` field in the prompt directs the model to draw only what's relevant to that section's scope.
|
|
||||||
|
|
||||||
**Prompt requirements:**
|
|
||||||
- 300–600 words of substantive prose
|
|
||||||
- Do NOT include a `# Title` heading (title is set separately)
|
|
||||||
- End with a brief `## Sources` list of relevant URLs from the provided sources
|
|
||||||
- Focus strictly on `section_focus` — ignore source material outside that scope
|
|
||||||
|
|
||||||
**Model params:** `num_predict=2048, num_ctx=16384` (reduced from 8192 — sufficient for 600 words, prevents rambling)
|
|
||||||
|
|
||||||
**Parallelism:** All section synthesis calls run via `asyncio.gather`. Wall-clock time stays close to a single synthesis call despite producing N notes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Note Creation and Index Note
|
|
||||||
|
|
||||||
**Section notes:**
|
|
||||||
- Tags: `["research"]`
|
|
||||||
- `project_id`: same as passed to pipeline (or None)
|
|
||||||
- Title: from outline `title` field
|
|
||||||
- Created sequentially (avoids DB contention)
|
|
||||||
|
|
||||||
**Index note:**
|
|
||||||
- Tags: `["research", "research-index"]`
|
|
||||||
- `project_id`: same as section notes
|
|
||||||
- Title: `"Research: [topic]"`
|
|
||||||
- Created last (after all section notes exist)
|
|
||||||
|
|
||||||
**Index note body format:**
|
|
||||||
```markdown
|
|
||||||
Research overview for **[topic]** — [YYYY-MM-DD]
|
|
||||||
|
|
||||||
Generated from [N] web sources across [M] sections.
|
|
||||||
|
|
||||||
## Sections
|
|
||||||
|
|
||||||
- **[Section 1 Title]** — [focus sentence]
|
|
||||||
- **[Section 2 Title]** — [focus sentence]
|
|
||||||
...
|
|
||||||
|
|
||||||
*Search for any section title to read it.*
|
|
||||||
```
|
|
||||||
|
|
||||||
The index note is what `run_research_pipeline` returns. The existing `research_topic` tool handler uses `note.id` and `note.title` — both remain valid with the index note.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
| Scenario | Behaviour |
|
|
||||||
|---|---|
|
|
||||||
| Outline generation raises | Fall back to single-note synthesis (current behaviour) |
|
|
||||||
| Outline JSON unparseable | Fall back to single-note synthesis |
|
|
||||||
| Outline returns < 3 sections | Fall back to single-note synthesis |
|
|
||||||
| Outline returns > 8 sections | Truncate to 8, continue |
|
|
||||||
| A section synthesis raises | Log warning, skip that section; continue with remaining |
|
|
||||||
| All section syntheses fail | Fall back to single-note synthesis |
|
|
||||||
| A section note DB save fails | Log warning, skip from index; index note still created |
|
|
||||||
| No sources fetched | Raise `ValueError` as today — unchanged |
|
|
||||||
|
|
||||||
The fallback in every case is the current single-note pipeline. Research never silently produces nothing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Is NOT Changing
|
|
||||||
|
|
||||||
- Public function signature of `run_research_pipeline`
|
|
||||||
- Sub-query generation (`_generate_sub_queries`)
|
|
||||||
- SearXNG search and URL fetching
|
|
||||||
- `_search_searxng`, `_search_searxng_images`, `fetch_url_content`
|
|
||||||
- The `research_topic` tool definition and handler in `tools.py`
|
|
||||||
- The `quick_capture` research path
|
|
||||||
- Any frontend component
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
# Settings Consistency Pass — Design
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Fix five interrelated gaps in the settings UI — missing timezone field, SSO-unaware account tab, duplicated work schedule, ignored slot toggles, and timezone changes not propagating to the briefing scheduler.
|
|
||||||
|
|
||||||
**Architecture:** Primarily frontend cleanup with two focused backend hooks: settings PUT route gains a timezone→scheduler bridge; briefing scheduler gains slot-gating and work-day awareness.
|
|
||||||
|
|
||||||
**Tech Stack:** Vue 3 + TypeScript frontend; Python/Quart backend; APScheduler; `zoneinfo`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem summary
|
|
||||||
|
|
||||||
1. **No timezone field** — `user_timezone` is read by the scheduler and the chat pipeline but is never exposed in the UI. The briefing tab displays the browser's detected timezone but never persists it. Scheduler falls back to UTC.
|
|
||||||
|
|
||||||
2. **Account tab ignores SSO** — "Email Address" and "Change Password" sections are shown to SSO users (`has_password = false`) even though they cannot change credentials here.
|
|
||||||
|
|
||||||
3. **Work schedule duplicated** — Profile tab has the canonical work schedule (days + start/end time, stored in `profile.work_schedule`). Briefing tab has a redundant "Office Days" section (`briefing_config.work_days`) that the backend never reads.
|
|
||||||
|
|
||||||
4. **Slot toggles are decorative** — The briefing tab's four slot checkboxes are saved to `briefing_config.slots` but `_add_user_jobs` schedules all four slots unconditionally.
|
|
||||||
|
|
||||||
5. **Timezone setting not propagated** — `PUT /api/settings` saves `user_timezone` to the DB but does not call `update_user_schedule`, so the in-memory scheduler keeps the stale timezone until restart or briefing config re-save.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### 1. General tab — Timezone field
|
|
||||||
|
|
||||||
**File:** `frontend/src/views/SettingsView.vue`
|
|
||||||
|
|
||||||
New section in the General tab (after the Assistant section, before Model Management):
|
|
||||||
|
|
||||||
```html
|
|
||||||
<section class="settings-section full-width">
|
|
||||||
<h2>Timezone</h2>
|
|
||||||
<p class="section-desc">Used to schedule briefings and format times in chat.</p>
|
|
||||||
<div class="field">
|
|
||||||
<label for="user-timezone">Your timezone</label>
|
|
||||||
<div style="display:flex; gap:0.5rem; align-items:center">
|
|
||||||
<input id="user-timezone" v-model="userTimezone" type="text"
|
|
||||||
class="input" placeholder="e.g. America/New_York" />
|
|
||||||
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
|
|
||||||
</div>
|
|
||||||
<p class="field-hint">IANA timezone name (e.g. America/Chicago, Europe/London).</p>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
|
|
||||||
{{ savingTimezone ? 'Saving…' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
```
|
|
||||||
|
|
||||||
- `detectTimezone()` sets `userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone`
|
|
||||||
- `saveTimezone()` calls `PUT /api/settings` with `{ user_timezone: userTimezone }`
|
|
||||||
- Loaded in `onMounted` / general settings load alongside `assistantName`, `defaultModel`
|
|
||||||
|
|
||||||
The briefing tab's "Firing in timezone" hint changes from the live Intl API to reading the stored `user_timezone` value:
|
|
||||||
```
|
|
||||||
Firing in timezone: <strong>{{ userTimezone || 'UTC (not set)' }}</strong>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Account tab — SSO guard
|
|
||||||
|
|
||||||
**File:** `frontend/src/views/SettingsView.vue`
|
|
||||||
|
|
||||||
Wrap the Email and Password sections:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- SSO info banner (shown when no local password) -->
|
|
||||||
<section v-if="!authStore.user?.has_password" class="settings-section">
|
|
||||||
<h2>Account</h2>
|
|
||||||
<p class="section-desc">
|
|
||||||
Your account is managed by an external identity provider.
|
|
||||||
Email and password changes are made through your provider, not here.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Local-auth sections (hidden for SSO) -->
|
|
||||||
<template v-if="authStore.user?.has_password">
|
|
||||||
<section class="settings-section"> <!-- Email Address --> </section>
|
|
||||||
<section class="settings-section"> <!-- Change Password --> </section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Active Sessions — always shown -->
|
|
||||||
<section class="settings-section"> ... </section>
|
|
||||||
```
|
|
||||||
|
|
||||||
No backend change needed — the API already rejects email/password changes for SSO accounts.
|
|
||||||
|
|
||||||
### 3. Briefing tab — Remove Office Days
|
|
||||||
|
|
||||||
**File:** `frontend/src/views/SettingsView.vue`
|
|
||||||
|
|
||||||
Delete the "Office Days" `<section>` (lines ~2068–2082). The `briefing_config.work_days` field can remain in the config object for backwards compatibility but the UI stops writing it.
|
|
||||||
|
|
||||||
The slot toggles section stays — it now actually drives scheduling (see §5).
|
|
||||||
|
|
||||||
### 4. Backend — settings PUT propagates timezone to scheduler
|
|
||||||
|
|
||||||
**File:** `src/fabledassistant/routes/settings.py`
|
|
||||||
|
|
||||||
After `set_settings_batch`, add:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if "user_timezone" in to_save:
|
|
||||||
import json
|
|
||||||
from fabledassistant.services.briefing_scheduler import update_user_schedule
|
|
||||||
config_raw = await get_setting(uid, "briefing_config", "{}")
|
|
||||||
try:
|
|
||||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
|
||||||
except Exception:
|
|
||||||
config = {}
|
|
||||||
if config.get("enabled"):
|
|
||||||
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Backend — scheduler respects slot toggles and work days
|
|
||||||
|
|
||||||
**File:** `src/fabledassistant/services/briefing_scheduler.py`
|
|
||||||
|
|
||||||
**5a. `_add_user_jobs` — only schedule enabled slots**
|
|
||||||
|
|
||||||
Change signature to accept `config: dict`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
|
||||||
enabled_slots = (config or {}).get("slots", {})
|
|
||||||
for slot_name, hour, minute in SLOTS:
|
|
||||||
# Default True for compilation (always run); others respect toggle
|
|
||||||
if slot_name != "compilation" and not enabled_slots.get(slot_name, True):
|
|
||||||
jid = _job_id(user_id, slot_name)
|
|
||||||
if _scheduler and _scheduler.get_job(jid):
|
|
||||||
_scheduler.remove_job(jid)
|
|
||||||
continue
|
|
||||||
_scheduler.add_job(
|
|
||||||
_run_user_slot_sync,
|
|
||||||
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
|
||||||
args=[user_id, slot_name],
|
|
||||||
id=_job_id(user_id, slot_name),
|
|
||||||
replace_existing=True,
|
|
||||||
misfire_grace_time=3600,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Update callers:
|
|
||||||
- `update_user_schedule(user_id, config, tz_override)` → pass `config` to `_add_user_jobs`
|
|
||||||
- `start_briefing_scheduler` startup loop → fetch full config to pass through
|
|
||||||
|
|
||||||
**5b. `_run_slot_for_user` — skip morning on non-work days**
|
|
||||||
|
|
||||||
For the `morning` slot, check today against `profile.work_schedule.days`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if slot == "morning":
|
|
||||||
from fabledassistant.services.user_profile import get_profile
|
|
||||||
from datetime import datetime
|
|
||||||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
|
||||||
try:
|
|
||||||
user_tz = ZoneInfo(tz_str)
|
|
||||||
except Exception:
|
|
||||||
user_tz = ZoneInfo("UTC")
|
|
||||||
today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', …
|
|
||||||
profile = await get_profile(user_id)
|
|
||||||
work_days = (profile.work_schedule or {}).get("days", ["Mon","Tue","Wed","Thu","Fri"])
|
|
||||||
if today_abbr not in work_days:
|
|
||||||
logger.info("Skipping morning slot for user %d — %s not a work day", user_id, today_abbr)
|
|
||||||
return
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `get_profile` must be importable from `user_profile.py` — confirm signature during implementation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
1. User opens Settings → General tab loads, reads `user_timezone` from `GET /api/settings`, populates the field
|
|
||||||
2. User clicks Detect → browser timezone fills the field
|
|
||||||
3. User clicks Save → `PUT /api/settings {user_timezone: "America/New_York"}` → backend saves and immediately calls `update_user_schedule` if briefing enabled
|
|
||||||
4. Briefing tab "Firing in timezone" now shows stored value instead of live browser API
|
|
||||||
5. Next 8am job: scheduler checks if `morning` is enabled in `briefing_config.slots`, then checks if today is in `profile.work_schedule.days` before running
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
| Scenario | Behaviour |
|
|
||||||
|---|---|
|
|
||||||
| `user_timezone` saved as empty string | `update_user_schedule` called with `tz_override=None` → falls back to `briefing_config.timezone` or UTC |
|
|
||||||
| Invalid IANA string saved | `_resolve_timezone` already falls back to UTC with a warning log |
|
|
||||||
| `profile.work_schedule` is None | `morning` slot defaults to Mon–Fri |
|
|
||||||
| Slot toggles key missing from config | All non-compilation slots default to enabled (`True`) — no regression for existing users |
|
|
||||||
| SSO user visits Account tab | Sees info banner; email/password forms hidden; no API calls attempted |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What is NOT changing
|
|
||||||
|
|
||||||
- Profile "Interests" and Briefing "News Preferences" remain separate — they serve different purposes (system-prompt personalisation vs RSS topic filtering)
|
|
||||||
- `briefing_config.work_days` field is not deleted from existing configs — just stops being written by the UI
|
|
||||||
- No migration needed — `profile.work_schedule.days` already exists; scheduler change is additive
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
# Web Voice Overlay Polish — Implementation Spec
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection.
|
|
||||||
|
|
||||||
**Architecture:** A new `useSilenceDetector` composable wraps the Web Audio API `AnalyserNode` and fires a callback when sustained silence is detected. `VoiceOverlay` coordinates `useVoiceRecorder` and `useSilenceDetector`, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds the Space bar handler.
|
|
||||||
|
|
||||||
**Tech Stack:** Vue 3 Composition API, Web Audio API (`AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables, TypeScript.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Map
|
|
||||||
|
|
||||||
| Action | Path |
|
|
||||||
|--------|------|
|
|
||||||
| Create | `frontend/src/composables/useSilenceDetector.ts` |
|
|
||||||
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
|
|
||||||
| Modify | `frontend/src/components/VoiceOverlay.vue` |
|
|
||||||
| Modify | `frontend/src/App.vue` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: `useSilenceDetector` composable
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/src/composables/useSilenceDetector.ts`
|
|
||||||
|
|
||||||
### Interface
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export interface SilenceDetectorOptions {
|
|
||||||
thresholdDb?: number // default -40
|
|
||||||
silenceDurationMs?: number // default 1500
|
|
||||||
minRecordingMs?: number // default 500
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSilenceDetector(options?: SilenceDetectorOptions): {
|
|
||||||
amplitude: Readonly<Ref<number>> // 0–1, for visualization
|
|
||||||
start(stream: MediaStream, onSilence: () => void): void
|
|
||||||
stop(): void
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Behaviour
|
|
||||||
|
|
||||||
- `start(stream, onSilence)`:
|
|
||||||
1. Creates `AudioContext`
|
|
||||||
2. `createMediaStreamSource(stream)` → connects to `AnalyserNode` (fftSize 256)
|
|
||||||
3. Records `startedAt = Date.now()`
|
|
||||||
4. Starts a `setInterval` at 100ms that:
|
|
||||||
- Calls `analyser.getByteFrequencyData(dataArray)`
|
|
||||||
- Computes RMS amplitude → maps to 0–1 range for `amplitude.value`
|
|
||||||
- Converts to approximate dB: `db = 20 * log10(rms)` (clamp to -100 when rms === 0)
|
|
||||||
- If `db < thresholdDb`: increments `silenceMs += 100`; else resets `silenceMs = 0`
|
|
||||||
- If `silenceMs >= silenceDurationMs` AND `Date.now() - startedAt >= minRecordingMs`: clears interval, fires `onSilence()`
|
|
||||||
- `stop()`: clears interval, closes `AudioContext`, resets `amplitude.value = 0`
|
|
||||||
- Safe to call `stop()` multiple times (guard with null check)
|
|
||||||
- `amplitude` resets to 0 after `stop()`
|
|
||||||
|
|
||||||
### Full implementation
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { ref, readonly } from 'vue'
|
|
||||||
|
|
||||||
export interface SilenceDetectorOptions {
|
|
||||||
thresholdDb?: number
|
|
||||||
silenceDurationMs?: number
|
|
||||||
minRecordingMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
|
|
||||||
const {
|
|
||||||
thresholdDb = -40,
|
|
||||||
silenceDurationMs = 1500,
|
|
||||||
minRecordingMs = 500,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const amplitude = ref(0)
|
|
||||||
let audioCtx: AudioContext | null = null
|
|
||||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
|
||||||
let silenceMs = 0
|
|
||||||
let startedAt = 0
|
|
||||||
|
|
||||||
function start(stream: MediaStream, onSilence: () => void) {
|
|
||||||
stop()
|
|
||||||
audioCtx = new AudioContext()
|
|
||||||
const source = audioCtx.createMediaStreamSource(stream)
|
|
||||||
const analyser = audioCtx.createAnalyser()
|
|
||||||
analyser.fftSize = 256
|
|
||||||
source.connect(analyser)
|
|
||||||
|
|
||||||
const data = new Uint8Array(analyser.frequencyBinCount)
|
|
||||||
silenceMs = 0
|
|
||||||
startedAt = Date.now()
|
|
||||||
|
|
||||||
intervalId = setInterval(() => {
|
|
||||||
analyser.getByteFrequencyData(data)
|
|
||||||
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
|
|
||||||
amplitude.value = rms
|
|
||||||
|
|
||||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100
|
|
||||||
if (db < thresholdDb) {
|
|
||||||
silenceMs += 100
|
|
||||||
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
|
|
||||||
stop()
|
|
||||||
onSilence()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
silenceMs = 0
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
if (intervalId !== null) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = null
|
|
||||||
}
|
|
||||||
if (audioCtx) {
|
|
||||||
audioCtx.close().catch(() => {})
|
|
||||||
audioCtx = null
|
|
||||||
}
|
|
||||||
amplitude.value = 0
|
|
||||||
silenceMs = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return { amplitude: readonly(amplitude), start, stop }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Write the file exactly as above
|
|
||||||
- [ ] Verify TypeScript compiles: `cd frontend && npx tsc --noEmit`
|
|
||||||
- [ ] Commit: `git add frontend/src/composables/useSilenceDetector.ts && git commit -m "feat: add useSilenceDetector composable"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Expose `stream` from `useVoiceRecorder`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
|
|
||||||
|
|
||||||
Change the `stream` local variable to a `Ref<MediaStream | null>` and export it as readonly.
|
|
||||||
|
|
||||||
- [ ] Change `let stream: MediaStream | null = null` to `const streamRef = ref<MediaStream | null>(null)`
|
|
||||||
- [ ] Replace all `stream` assignments with `streamRef.value`:
|
|
||||||
- `stream = await navigator.mediaDevices.getUserMedia(...)` → `streamRef.value = await ...`
|
|
||||||
- `stream?.getTracks().forEach(...)` → `streamRef.value?.getTracks().forEach(...)`
|
|
||||||
- `stream = null` → `streamRef.value = null`
|
|
||||||
- [ ] Add `stream: readonly(streamRef)` to the return object
|
|
||||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
|
||||||
- [ ] Commit: `git add frontend/src/composables/useVoiceRecorder.ts && git commit -m "feat: expose stream ref from useVoiceRecorder"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Wire `VoiceOverlay` — silence detection + click-to-toggle
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/components/VoiceOverlay.vue`
|
|
||||||
|
|
||||||
### Script changes
|
|
||||||
|
|
||||||
- [ ] Import `useSilenceDetector` at the top of `<script setup>`
|
|
||||||
- [ ] Add `const silenceDetector = useSilenceDetector()` after the existing composable instantiations
|
|
||||||
- [ ] In `startPtt()`: after `phase.value = 'recording'`, add:
|
|
||||||
```ts
|
|
||||||
if (recorder.stream.value) {
|
|
||||||
silenceDetector.start(recorder.stream.value, stopPtt)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- [ ] In `stopPtt()`: add `silenceDetector.stop()` as the first line (before the guard check)
|
|
||||||
- [ ] In `cancelAll()`: add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`
|
|
||||||
|
|
||||||
### Button: click-to-toggle
|
|
||||||
|
|
||||||
Replace the PTT mouse/touch handlers on `.voice-ptt-btn` with click-to-toggle logic:
|
|
||||||
|
|
||||||
- [ ] Remove `@mousedown.prevent="startPtt"` and `@mouseup.prevent="stopPtt"`
|
|
||||||
- [ ] Remove `@touchstart.prevent="startPtt"` and `@touchend.prevent="stopPtt"`
|
|
||||||
- [ ] Replace `@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"` with:
|
|
||||||
```html
|
|
||||||
@click.prevent="onBtnClick"
|
|
||||||
```
|
|
||||||
- [ ] Add `onBtnClick` function in script:
|
|
||||||
```ts
|
|
||||||
function onBtnClick() {
|
|
||||||
if (phase.value === 'error') { phase.value = 'idle'; return }
|
|
||||||
if (phase.value === 'recording') { stopPtt(); return }
|
|
||||||
if (phase.value === 'idle') { startPtt() }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- [ ] Update `aria-label` and `title` on the button:
|
|
||||||
- `aria-label`: `phase === 'recording' ? 'Click to stop' : 'Click to speak'`
|
|
||||||
- `title`: `phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'`
|
|
||||||
|
|
||||||
### Amplitude visualization during recording
|
|
||||||
|
|
||||||
Inside the button, when `phase === 'recording'`, replace the static stop icon with animated amplitude bars:
|
|
||||||
|
|
||||||
- [ ] Replace the recording SVG block:
|
|
||||||
```html
|
|
||||||
<svg v-else-if="phase === 'recording'" ...>...</svg>
|
|
||||||
```
|
|
||||||
with:
|
|
||||||
```html
|
|
||||||
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
|
|
||||||
<span
|
|
||||||
v-for="n in 3"
|
|
||||||
:key="n"
|
|
||||||
class="voice-amp-bar"
|
|
||||||
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hint label
|
|
||||||
|
|
||||||
- [ ] Change the idle hint from `Hold <kbd>Space</kbd> or tap` to `Tap or press <kbd>Space</kbd>`
|
|
||||||
|
|
||||||
### CSS for amplitude bars
|
|
||||||
|
|
||||||
- [ ] Add to `<style scoped>`:
|
|
||||||
```css
|
|
||||||
.voice-amp-bars {
|
|
||||||
display: flex;
|
|
||||||
gap: 3px;
|
|
||||||
align-items: center;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
.voice-amp-bar {
|
|
||||||
width: 4px;
|
|
||||||
height: 18px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 2px;
|
|
||||||
transform-origin: center;
|
|
||||||
transition: transform 0.08s ease;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
|
||||||
- [ ] Commit: `git add frontend/src/components/VoiceOverlay.vue && git commit -m "feat: click-to-toggle silence detection in VoiceOverlay"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Mount overlay and wire Space bar in `App.vue`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/src/App.vue`
|
|
||||||
|
|
||||||
### Mount VoiceOverlay
|
|
||||||
|
|
||||||
- [ ] Add import at top of `<script setup>`:
|
|
||||||
```ts
|
|
||||||
import VoiceOverlay from '@/components/VoiceOverlay.vue'
|
|
||||||
```
|
|
||||||
- [ ] Add `<VoiceOverlay />` inside the `<template v-if="authStore.isAuthenticated">` block, just before `<ToastNotification />`:
|
|
||||||
```html
|
|
||||||
<VoiceOverlay />
|
|
||||||
<ToastNotification />
|
|
||||||
```
|
|
||||||
|
|
||||||
### Space bar handler
|
|
||||||
|
|
||||||
- [ ] In `onGlobalKeydown`, add a `Space` case inside the `switch (e.key)` block (after the existing cases), only fires when `!isInputActive()`:
|
|
||||||
```ts
|
|
||||||
case ' ':
|
|
||||||
e.preventDefault()
|
|
||||||
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
|
|
||||||
break
|
|
||||||
```
|
|
||||||
|
|
||||||
### Shortcuts panel label
|
|
||||||
|
|
||||||
- [ ] Update the Space shortcut description from `Hold to speak (voice, when enabled)` to `Tap to speak (voice, when enabled)`
|
|
||||||
|
|
||||||
- [ ] Verify TypeScript: `npx tsc --noEmit`
|
|
||||||
- [ ] Verify full build: `npm run build`
|
|
||||||
- [ ] Commit: `git add frontend/src/App.vue && git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut"`
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# Knowledge View Task Consolidation — Design Spec
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Consolidate tasks into the Knowledge view as a card type, deprecate the standalone `/notes` and `/tasks` list views, and simplify navigation. The Knowledge view becomes the single hub for all content types: notes, tasks, people, places, and lists.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The Knowledge view already renders notes, people, places, and lists as typed cards in a filterable grid with a sidebar. Tasks are added as a fifth card type using the same two-tier pagination system (ID pre-fetch → content batch). The backend knowledge endpoints (`/api/knowledge/ids`, `/api/knowledge/batch`, `/api/knowledge/counts`) are extended to include tasks. No changes to the note/task CRUD API.
|
|
||||||
|
|
||||||
## Task Cards
|
|
||||||
|
|
||||||
Task cards follow the same layout as other knowledge cards:
|
|
||||||
|
|
||||||
- **Left accent strip**: distinct color for tasks (e.g. `#a78bfa` purple to differentiate from note indigo)
|
|
||||||
- **Type badge**: "Task" in top-right corner
|
|
||||||
- **Card body**:
|
|
||||||
- Title (2-line clamp)
|
|
||||||
- Status badge: `todo` / `in_progress` / `done` / `cancelled` — styled with existing status colors from theme (`--color-status-*`)
|
|
||||||
- Priority indicator: shown only when priority is not `none` — uses existing priority colors (`--color-priority-*`)
|
|
||||||
- Due date: shown when set, with overdue styling (`--color-overdue`) when past and status is not `done`/`cancelled`
|
|
||||||
- **Card footer**: tags (up to 3) + last-modified date — identical to other card types
|
|
||||||
|
|
||||||
Clicking a task card navigates to `/tasks/:id/edit` (same as today).
|
|
||||||
|
|
||||||
## Filter Sidebar Changes
|
|
||||||
|
|
||||||
The type filter section gains a "Tasks" button:
|
|
||||||
|
|
||||||
```
|
|
||||||
Type
|
|
||||||
──────────
|
|
||||||
[All] 127
|
|
||||||
[Notes] 84
|
|
||||||
[Tasks] 22
|
|
||||||
[People] 8
|
|
||||||
[Places] 5
|
|
||||||
[Lists] 8
|
|
||||||
```
|
|
||||||
|
|
||||||
The filter value for tasks is `type=task`. The backend already stores tasks as notes with `is_task=True`; the knowledge endpoints need to map the `type=task` filter to `is_task=True`.
|
|
||||||
|
|
||||||
## New Note Button Interaction
|
|
||||||
|
|
||||||
Current: click "New note" to create a note; chevron expands a dropdown with Note/Person/Place/List.
|
|
||||||
|
|
||||||
New behavior:
|
|
||||||
|
|
||||||
1. **Click "New note"** (when collapsed) → expands to reveal type options: Task, Person, Place, List. The main button label does not change.
|
|
||||||
2. **Click "New note"** again (when expanded) → navigates to `/notes/new` (generic note).
|
|
||||||
3. **Click any type option** → navigates to `/notes/new?type=<type>` (for task: `/notes/new?type=task`, which is equivalent to `/tasks/new`).
|
|
||||||
4. **Click outside** → collapses the dropdown.
|
|
||||||
|
|
||||||
This replaces the current chevron split-button pattern with a simpler toggle. The dropdown items are: Task, Person, Place, List (no "Note" item in the dropdown — clicking the button itself creates a note).
|
|
||||||
|
|
||||||
## Route Changes
|
|
||||||
|
|
||||||
### Redirects
|
|
||||||
|
|
||||||
| Old route | New behavior |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `/notes` | 302 redirect → `/` (Knowledge view) |
|
|
||||||
| `/tasks` | 302 redirect → `/` (Knowledge view) |
|
|
||||||
|
|
||||||
### Preserved routes (no change)
|
|
||||||
|
|
||||||
| Route | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `/notes/:id` | Note viewer |
|
|
||||||
| `/notes/:id/edit` | Note editor |
|
|
||||||
| `/notes/new` | New note (with optional `?type=` param) |
|
|
||||||
| `/tasks/:id/edit` | Task editor |
|
|
||||||
| `/tasks/new` | New task |
|
|
||||||
|
|
||||||
### Router implementation
|
|
||||||
|
|
||||||
Add redirect entries in the router config:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{ path: '/notes', redirect: '/' },
|
|
||||||
{ path: '/tasks', redirect: '/' },
|
|
||||||
```
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
|
|
||||||
Remove from `AppHeader.vue`:
|
|
||||||
- "Tasks" nav link (`<router-link to="/tasks">`)
|
|
||||||
- The `/tasks` entry in both desktop nav-center and mobile menu
|
|
||||||
|
|
||||||
Remove from `AppHeader.vue` (already done — `/notes` was removed in a prior change, but verify).
|
|
||||||
|
|
||||||
### Deleted files
|
|
||||||
|
|
||||||
- `frontend/src/views/NotesListView.vue`
|
|
||||||
- `frontend/src/views/TasksListView.vue`
|
|
||||||
- `frontend/src/stores/notes.ts` (if only used by NotesListView)
|
|
||||||
- `frontend/src/stores/tasks.ts` (if only used by TasksListView)
|
|
||||||
|
|
||||||
Verify no other components import from these before deleting. The note/task viewer and editor screens import from `api/client.ts` directly, not from the list stores.
|
|
||||||
|
|
||||||
## Backend Changes
|
|
||||||
|
|
||||||
### `/api/knowledge/ids`
|
|
||||||
|
|
||||||
Accept `type=task` as a valid filter. When `type=task`, query `notes` table with `is_task = True`. When `type` is not set (all), include tasks in results alongside notes/people/places/lists.
|
|
||||||
|
|
||||||
### `/api/knowledge/batch`
|
|
||||||
|
|
||||||
Return task-specific fields for items where `is_task = True`:
|
|
||||||
- `status`: todo / in_progress / done / cancelled
|
|
||||||
- `priority`: none / low / normal / high
|
|
||||||
- `due_date`: ISO date string or null
|
|
||||||
|
|
||||||
These are already columns on the `Note` model — just include them in the batch response when the item is a task.
|
|
||||||
|
|
||||||
### `/api/knowledge/counts`
|
|
||||||
|
|
||||||
Add `task` to the counts response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "note": 84, "task": 22, "person": 8, "place": 5, "list": 8, "total": 127 }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `/api/knowledge/tags`
|
|
||||||
|
|
||||||
No change — tasks already have tags on the same `Note` model.
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
|
||||||
|
|
||||||
Remove from `App.vue` `onGlobalKeydown`:
|
|
||||||
- `case "t": router.push("/tasks/new")` — keep this, it still works
|
|
||||||
- `case "g"` sequence `case "t": router.push("/tasks")` — change to `router.push("/")` (direct navigation, don't rely on redirect)
|
|
||||||
|
|
||||||
Update shortcuts overlay panel text if it references "Tasks list".
|
|
||||||
|
|
||||||
## No API Endpoint Changes
|
|
||||||
|
|
||||||
All existing REST endpoints remain:
|
|
||||||
- `GET/POST /api/notes` — notes CRUD
|
|
||||||
- `GET/POST /api/tasks` — tasks CRUD
|
|
||||||
- `PATCH /api/notes/:id`, `PATCH /api/tasks/:id`
|
|
||||||
- `DELETE /api/notes/:id`, `DELETE /api/tasks/:id`
|
|
||||||
|
|
||||||
MCP tools (`fable_create_task`, `fable_list_tasks`, etc.) are unaffected.
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
# Specialized Note Type Editors — Design Spec
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Replace the one-size-fits-all note editor with type-specialized views for Person, Place, and List. Each type gets a form-first layout where structured fields are the main content, with a secondary notes area for free text. Fix tab navigation across all note types so focus flows logically from title through fields to body, skipping the formatting toolbar.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The existing `NoteEditorView.vue` remains the single editor component but renders different layouts based on `noteType`. When `noteType` is `person`, `place`, or `list`, the main editor area switches from TipTap-first to form-first. The TipTap editor moves to a secondary "Notes" section below the form fields. The sidebar metadata fields for person/place move into the main content area. The `note_type` field, entity metadata storage, and API contract are unchanged.
|
|
||||||
|
|
||||||
## Person Editor
|
|
||||||
|
|
||||||
When `noteType === 'person'`, the main content area renders a contact card form instead of the TipTap editor.
|
|
||||||
|
|
||||||
### Fields (in order, all in main content area)
|
|
||||||
|
|
||||||
| Field | Type | Placeholder | Source |
|
|
||||||
|-------|------|-------------|--------|
|
|
||||||
| Name | text input (title) | "Name" | `title` |
|
|
||||||
| Relationship | text input | "e.g. Friend, Colleague, Family" | `entityMeta.relationship` |
|
|
||||||
| Birthday | date input | — | `entityMeta.birthday` (new field) |
|
|
||||||
| Email | email input | "email@example.com" | `entityMeta.email` |
|
|
||||||
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
|
|
||||||
| Organization | text input | "Company or organization" | `entityMeta.organization` (new field) |
|
|
||||||
| Address | text input | "Street, City, State" | `entityMeta.address` (new field for person) |
|
|
||||||
|
|
||||||
### Notes section
|
|
||||||
|
|
||||||
Below the form fields, a collapsible "Notes" section with the TipTap editor for free-text content. This is where wikilinks, tags, and general context go. The section starts expanded if the note already has body content, collapsed if empty on a new note.
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────┐
|
|
||||||
│ [← Knowledge] [Save] [Delete] │
|
|
||||||
│ │
|
|
||||||
│ Name: [________________________________] │
|
|
||||||
│ │
|
|
||||||
│ Relationship: [________________________] │
|
|
||||||
│ Birthday: [____date picker________] │
|
|
||||||
│ Email: [________________________] │
|
|
||||||
│ Phone: [________________________] │
|
|
||||||
│ Organization: [________________________] │
|
|
||||||
│ Address: [________________________] │
|
|
||||||
│ │
|
|
||||||
│ ▾ Notes │
|
|
||||||
│ ┌──────────────────────────────────────┐ │
|
|
||||||
│ │ TipTap editor (markdown body) │ │
|
|
||||||
│ └──────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [sidebar: project/tags/etc] │
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data migration
|
|
||||||
|
|
||||||
Existing person notes may have structured data written as plain text in the body (e.g. "Relationship: daughter Birthday: 2013-12-13"). No automatic migration — the body content stays as-is in the Notes section. Users can move data to the structured fields manually.
|
|
||||||
|
|
||||||
## Place Editor
|
|
||||||
|
|
||||||
When `noteType === 'place'`, same form-first pattern.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
| Field | Type | Placeholder | Source |
|
|
||||||
|-------|------|-------------|--------|
|
|
||||||
| Name | text input (title) | "Place name" | `title` |
|
|
||||||
| Address | text input | "Street, City, State" | `entityMeta.address` |
|
|
||||||
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
|
|
||||||
| Hours | text input | "e.g. Mon–Fri 9am–5pm" | `entityMeta.hours` |
|
|
||||||
| Website | url input | "https://..." | `entityMeta.website` (new field) |
|
|
||||||
| Category | text input | "e.g. Restaurant, Office, Doctor" | `entityMeta.category` (new field) |
|
|
||||||
|
|
||||||
### Notes section
|
|
||||||
|
|
||||||
Same as Person — collapsible TipTap editor below the form.
|
|
||||||
|
|
||||||
## List Editor
|
|
||||||
|
|
||||||
When `noteType === 'list'`, the main content area renders a checklist builder instead of the TipTap editor.
|
|
||||||
|
|
||||||
### List builder
|
|
||||||
|
|
||||||
Each list item is a row with:
|
|
||||||
- Checkbox (toggle checked state)
|
|
||||||
- Text input (item text, fills available width)
|
|
||||||
- Delete button (× icon, right side)
|
|
||||||
|
|
||||||
Below the items: an "Add item" button.
|
|
||||||
|
|
||||||
### Behavior
|
|
||||||
|
|
||||||
- **Enter** in any item input: creates a new item below and focuses it
|
|
||||||
- **Backspace** on an empty item: deletes the item and focuses the previous one
|
|
||||||
- **Checkbox toggle**: updates the item's checked state
|
|
||||||
- **Delete button**: removes the item
|
|
||||||
|
|
||||||
### Serialization
|
|
||||||
|
|
||||||
On save, list items are serialized to markdown checkbox format in the body:
|
|
||||||
```markdown
|
|
||||||
- [ ] Buy groceries
|
|
||||||
- [x] Call dentist
|
|
||||||
- [ ] Pick up prescription
|
|
||||||
```
|
|
||||||
|
|
||||||
On load, the body is parsed back into structured items (same parser already exists in `knowledge.py` and `KnowledgeView.vue`).
|
|
||||||
|
|
||||||
### Notes section
|
|
||||||
|
|
||||||
Same collapsible TipTap "Notes" section below the list builder, for additional context that isn't a list item.
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────┐
|
|
||||||
│ [← Knowledge] [Save] [Delete] │
|
|
||||||
│ │
|
|
||||||
│ List title: [____________________________│
|
|
||||||
│ │
|
|
||||||
│ [ ] Buy groceries [×] │
|
|
||||||
│ [x] Call dentist [×] │
|
|
||||||
│ [ ] Pick up prescription [×] │
|
|
||||||
│ │
|
|
||||||
│ [+ Add item] │
|
|
||||||
│ │
|
|
||||||
│ ▾ Notes │
|
|
||||||
│ ┌──────────────────────────────────────┐ │
|
|
||||||
│ │ TipTap editor (additional context) │ │
|
|
||||||
│ └──────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [sidebar: project/tags/etc] │
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tab Navigation & Auto-Focus
|
|
||||||
|
|
||||||
### All note types
|
|
||||||
|
|
||||||
1. **On page load**: focus the title/name input automatically
|
|
||||||
2. **Tab from title**: skip the formatting toolbar entirely, go to the first content field:
|
|
||||||
- Note: TipTap editor body
|
|
||||||
- Person: Relationship field
|
|
||||||
- Place: Address field
|
|
||||||
- List: first list item (or "Add item" button if empty)
|
|
||||||
3. **Tab through fields**: natural order through all form fields
|
|
||||||
4. **Tab from last form field**: enter the Notes section (TipTap editor)
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
Set `tabindex="-1"` on all MarkdownToolbar buttons so they are clickable but not in the tab order. The toolbar remains fully functional via mouse/touch — it's just skipped when tabbing.
|
|
||||||
|
|
||||||
### Title placeholder by type
|
|
||||||
|
|
||||||
| Type | Placeholder |
|
|
||||||
|------|-------------|
|
|
||||||
| Note | "Title" |
|
|
||||||
| Person | "Name" |
|
|
||||||
| Place | "Place name" |
|
|
||||||
| List | "List title" |
|
|
||||||
| Task | "Title" (unchanged, task editor is separate) |
|
|
||||||
|
|
||||||
## Sidebar changes
|
|
||||||
|
|
||||||
When editing a Person or Place, the type-specific metadata fields (Relationship, Email, Phone, etc.) **move from the sidebar to the main content area**. The sidebar keeps: Project, Milestone, Tags, Suggest Tags, Type selector, Link Suggestions, Writing Assistant, Version History.
|
|
||||||
|
|
||||||
The Type selector remains in the sidebar so users can change the type if needed. Changing type switches the layout.
|
|
||||||
|
|
||||||
## Backend changes
|
|
||||||
|
|
||||||
### New entity metadata fields
|
|
||||||
|
|
||||||
The `entity_meta` JSON column on the Note model already stores arbitrary key-value pairs. No schema migration needed — just store the new keys:
|
|
||||||
|
|
||||||
- Person: `birthday`, `organization`, `address` (new; `relationship`, `email`, `phone` existing)
|
|
||||||
- Place: `website`, `category` (new; `address`, `phone`, `hours` existing)
|
|
||||||
|
|
||||||
### Knowledge service
|
|
||||||
|
|
||||||
Update `_note_to_item` in `services/knowledge.py` to include the new fields in the response for person and place cards:
|
|
||||||
|
|
||||||
- Person: add `birthday`, `organization`, `address`
|
|
||||||
- Place: add `website`, `category`
|
|
||||||
|
|
||||||
### Knowledge card display
|
|
||||||
|
|
||||||
Update `KnowledgeView.vue` card rendering to show the new fields where useful (e.g. organization on person cards, category on place cards).
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `frontend/src/views/NoteEditorView.vue` | Type-conditional layouts, form fields, list builder, tab navigation, auto-focus, title placeholders |
|
|
||||||
| `frontend/src/views/KnowledgeView.vue` | Card display for new person/place fields |
|
|
||||||
| `frontend/src/components/MarkdownToolbar.vue` | `tabindex="-1"` on all buttons |
|
|
||||||
| `src/fabledassistant/services/knowledge.py` | New fields in `_note_to_item` for person/place |
|
|
||||||
|
|
||||||
## What does NOT change
|
|
||||||
|
|
||||||
- Note model / database schema (entity_meta is already a JSON column)
|
|
||||||
- API endpoints (same CRUD)
|
|
||||||
- Task editor (`TaskEditorView.vue`) — separate component, unchanged
|
|
||||||
- Generic note editing — TipTap-first layout stays for `noteType === 'note'`
|
|
||||||
- Backend storage format — entity_meta key-value pairs
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
# Modern Fable — Visual Identity Design Spec
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Replace the generic "competent dark-mode Vue app" aesthetic with a distinctive visual identity that is unmistakably Fabled Assistant. The design language evolves from "Illuminated Transcript" to "Modern Fable" — keeping the scholarly DNA but adding personality through color, typography, interaction, and card design that no other app has.
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
Shift from indigo (`#6366f1`) to deep violet + muted gold.
|
|
||||||
|
|
||||||
### Dark theme
|
|
||||||
|
|
||||||
| Role | Old | New | Usage |
|
|
||||||
|------|-----|-----|-------|
|
|
||||||
| Primary | `#818cf8` | `#a78bfa` | Text accents, active states, tags, links |
|
|
||||||
| Primary solid | `#6366f1` | `#7c3aed` | Buttons, gradients, accent strips |
|
|
||||||
| Primary deep | `#4f46e5` | `#5b21b6` | Gradient endpoints, hover states |
|
|
||||||
| Accent (warm) | — | `#d4a017` | Due dates, event times, counts, temporal data |
|
|
||||||
| Accent light | — | `#e8c45a` | Amber hover states |
|
|
||||||
| Background | `#111113` | `#0f0f14` | Slightly deeper, more dramatic |
|
|
||||||
| Surface | `#1a1b22` | `#16161f` | Cards, panels |
|
|
||||||
| Card bg | `#1e1e27` | `#1a1a24` | Card interiors |
|
|
||||||
| Border | `rgba(99,102,241,0.10)` | `rgba(124,58,237,0.12)` | Violet-tinted borders |
|
|
||||||
| Text | `#e4e4f0` | `#e4e4f0` | Unchanged |
|
|
||||||
| Text muted | `#52526a` | `#52526a` | Unchanged |
|
|
||||||
|
|
||||||
### Light theme
|
|
||||||
|
|
||||||
| Role | Old | New |
|
|
||||||
|------|-----|-----|
|
|
||||||
| Primary | `#6366f1` | `#7c3aed` |
|
|
||||||
| Primary text | `#4f46e5` | `#5b21b6` |
|
|
||||||
| Accent | — | `#b8860b` (darker gold for light bg) |
|
|
||||||
| Tag bg | `#ede9fe` | `#ede5ff` |
|
|
||||||
| Tag text | `#4f46e5` | `#6d28d9` |
|
|
||||||
|
|
||||||
### Semantic color rules
|
|
||||||
|
|
||||||
- **Violet = structural** — navigation, type badges, status indicators, card accents, CTA buttons
|
|
||||||
- **Amber/gold = temporal** — due dates, event times, countdown values, "overdue" states, calendar dot, relative timestamps
|
|
||||||
- This duality is a core brand principle: violet organizes, amber marks time
|
|
||||||
|
|
||||||
### Logo update
|
|
||||||
|
|
||||||
Update `AppLogo.vue` SVG fill to use the new violet gradient (`#7c3aed` → `#5b21b6`) instead of the current indigo values.
|
|
||||||
|
|
||||||
## Card Design — Type DNA
|
|
||||||
|
|
||||||
Each content type gets a distinct visual signature recognizable at a glance without reading the badge.
|
|
||||||
|
|
||||||
### Shared card structure
|
|
||||||
|
|
||||||
- Background: `var(--color-surface)`
|
|
||||||
- Border: `1px solid` with type-tinted color at low opacity
|
|
||||||
- Border-radius: `var(--radius-lg)` (14px)
|
|
||||||
- Padding: 14px
|
|
||||||
- Hover: translateY(-2px) + violet shadow bloom (`0 8px 24px rgba(124,58,237,0.15)`)
|
|
||||||
|
|
||||||
### Type-specific signatures
|
|
||||||
|
|
||||||
**Note** (`note`)
|
|
||||||
- Top edge: full-width 3px gradient bar (`#7c3aed` → `#a78bfa`)
|
|
||||||
- Border tint: `rgba(124,58,237,0.12)`
|
|
||||||
- Badge color: `#a78bfa`
|
|
||||||
|
|
||||||
**Task** (`task`)
|
|
||||||
- Top edge: half-width 3px gradient bar (`#d4a017` → transparent`), left-aligned — partial bar suggests "in progress"
|
|
||||||
- Border tint: `rgba(212,160,23,0.10)`
|
|
||||||
- Badge color: `#d4a017`
|
|
||||||
- Status badge inline with type badge row
|
|
||||||
- Due date in amber; overdue in `--color-overdue` (red)
|
|
||||||
|
|
||||||
**Person** (`person`)
|
|
||||||
- Top edge: none
|
|
||||||
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(16,185,129,0.06)`)
|
|
||||||
- Border tint: `rgba(16,185,129,0.10)`
|
|
||||||
- Badge color: `#34d399`
|
|
||||||
|
|
||||||
**Place** (`place`)
|
|
||||||
- Top edge: none
|
|
||||||
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(245,158,11,0.06)`)
|
|
||||||
- Border tint: `rgba(245,158,11,0.10)`
|
|
||||||
- Badge color: `#fbbf24`
|
|
||||||
|
|
||||||
**List** (`list`)
|
|
||||||
- Top edge: full-width 3px gradient bar (`#38bdf8` → `#7dd3fc`)
|
|
||||||
- Border tint: `rgba(56,189,248,0.10)`
|
|
||||||
- Badge color: `#7dd3fc`
|
|
||||||
- Progress bar beneath checkboxes
|
|
||||||
|
|
||||||
### Card hover state
|
|
||||||
|
|
||||||
All cards share the same hover treatment:
|
|
||||||
```css
|
|
||||||
.k-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 24px rgba(124,58,237,0.15), 0 2px 8px rgba(0,0,0,0.3);
|
|
||||||
border-color: rgba(124,58,237,0.2);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Navigation — Signature Header
|
|
||||||
|
|
||||||
Replace the flat nav links with a pill-grouped tab bar.
|
|
||||||
|
|
||||||
### Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
[Logo + "Fabled"] [ Knowledge | Chat | Briefing | Calendar | News | Projects ] [status · ? · ☀ · ⚙ · user]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Brand in header
|
|
||||||
|
|
||||||
- Logo: `AppLogo` SVG at 28px with new violet gradient
|
|
||||||
- Text: "Fabled" only (not "Fabled Assistant") — Fraunces italic, `#c4b0f0`, 15px
|
|
||||||
- The full name "Fabled Assistant" appears on the login page and Settings; the header uses the short form
|
|
||||||
|
|
||||||
### Tab bar
|
|
||||||
|
|
||||||
- Container: `rgba(124,58,237,0.06)` background, `border-radius: 10px`, 3px padding
|
|
||||||
- Inactive tabs: transparent background, `color: var(--color-text-muted)`
|
|
||||||
- Active tab: `rgba(124,58,237,0.2)` background, `border-radius: 8px`, `color: #c4b5fd`, soft box-shadow glow `0 0 12px rgba(124,58,237,0.2)`
|
|
||||||
- Hover (inactive): `rgba(124,58,237,0.08)` background
|
|
||||||
- Transition: background 0.15s, color 0.15s
|
|
||||||
|
|
||||||
### Header background
|
|
||||||
|
|
||||||
Subtle gradient: `linear-gradient(180deg, var(--color-surface), var(--color-bg))` with a bottom border of `rgba(124,58,237,0.08)`. Creates depth without being heavy.
|
|
||||||
|
|
||||||
### Mobile
|
|
||||||
|
|
||||||
On mobile (< 768px), the pill bar collapses into the existing hamburger dropdown menu. The dropdown gets the same violet active styling.
|
|
||||||
|
|
||||||
## Typography — Fraunces as Narrator
|
|
||||||
|
|
||||||
Fraunces italic becomes the "narrator's voice" of the application — the assistant speaking through the UI. System UI font remains for body text and interactive elements.
|
|
||||||
|
|
||||||
### Where Fraunces is used
|
|
||||||
|
|
||||||
| Element | Style | Example |
|
|
||||||
|---------|-------|---------|
|
|
||||||
| View titles | Fraunces italic, 20-24px, `#c4b0f0` | *Knowledge*, *Chat*, *Briefing* |
|
|
||||||
| Sidebar section labels | Fraunces italic, 11px, `var(--color-primary)` | *Filter*, *Tags*, *Sort* |
|
|
||||||
| Empty states | Fraunces italic, 13-15px, `#d4a017` | *"Every story starts with a blank page."* |
|
|
||||||
| Card headings (h1/h2/h3) | Fraunces, non-italic, 600 weight | Existing behavior, unchanged |
|
|
||||||
| Briefing greeting | Fraunces italic, 16px | *"Good morning, Bryan"* |
|
|
||||||
|
|
||||||
### Where Fraunces is NOT used
|
|
||||||
|
|
||||||
- Navigation tab labels (system font, 12-13px)
|
|
||||||
- Buttons and form labels
|
|
||||||
- Card body text, snippets, metadata
|
|
||||||
- Toast messages, error text
|
|
||||||
|
|
||||||
### Empty state voice
|
|
||||||
|
|
||||||
Each major view gets a distinctive empty state message in Fraunces italic, amber color:
|
|
||||||
- Knowledge: *"Your story is unwritten. Create your first note to begin."*
|
|
||||||
- Chat: *"Start a conversation."*
|
|
||||||
- Calendar: *"No events ahead. A quiet chapter."*
|
|
||||||
- Briefing (no briefing yet): *"Your daily briefing will appear here each morning."*
|
|
||||||
|
|
||||||
## Living Details
|
|
||||||
|
|
||||||
Small touches that accumulate into a distinctive feel.
|
|
||||||
|
|
||||||
### Glow interactions
|
|
||||||
|
|
||||||
- **Buttons**: Primary buttons (`btn-send`, `btn-new-note`, CTAs) get a violet glow on hover: `box-shadow: 0 0 16px rgba(124,58,237,0.35)`
|
|
||||||
- **Focus ring**: Change from current `color-mix` to a violet glow: `0 0 0 2px rgba(124,58,237,0.4)`
|
|
||||||
- **Active nav tab**: Soft glow behind the active pill (see Navigation section)
|
|
||||||
|
|
||||||
### Amber for temporal data
|
|
||||||
|
|
||||||
Consistently use `#d4a017` (dark theme) for all time-related information:
|
|
||||||
- Due dates on task cards
|
|
||||||
- Event times on calendar chips
|
|
||||||
- "3d ago" timestamps on cards
|
|
||||||
- Overdue badge in the today bar
|
|
||||||
- Countdown/relative time in briefing
|
|
||||||
|
|
||||||
This creates a visual language: when you see amber, it's about *when*.
|
|
||||||
|
|
||||||
### Card hover bloom
|
|
||||||
|
|
||||||
Cards lift and emit a violet shadow on hover (see Card Design section). The shadow color matches the card's type accent at very low opacity for a subtle differentiation.
|
|
||||||
|
|
||||||
### Status dot pulse
|
|
||||||
|
|
||||||
The Ollama status indicator in the header gains a CSS pulse animation when the model is loaded:
|
|
||||||
```css
|
|
||||||
@keyframes status-pulse {
|
|
||||||
0%, 100% { box-shadow: 0 0 4px rgba(74,222,128,0.4); }
|
|
||||||
50% { box-shadow: 0 0 10px rgba(74,222,128,0.6); }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Pulse only when status is "loaded" (green). Offline (red) and loading (amber) are static.
|
|
||||||
|
|
||||||
### Scroll edge fades
|
|
||||||
|
|
||||||
Top and bottom edges of scrollable areas (card grid, chat messages, sidebar tag list) get a gradient mask that fades content into the background. 20px height, using `mask-image: linear-gradient(...)`.
|
|
||||||
|
|
||||||
### Sidebar section dividers
|
|
||||||
|
|
||||||
Replace flat `border-bottom` between filter sections with a centered ornamental divider:
|
|
||||||
```css
|
|
||||||
.filter-section + .filter-section::before {
|
|
||||||
content: '·';
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(124,58,237,0.3);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
letter-spacing: 0.5em;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Three centered dots (` · · · `) in faint violet. Subtle but distinctive.
|
|
||||||
|
|
||||||
### Scrollbar
|
|
||||||
|
|
||||||
Keep the current thin scrollbar but update the color from indigo to violet:
|
|
||||||
```css
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(124,58,237,0.25);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `frontend/src/assets/theme.css` | Full palette update (both light and dark), scrollbar color |
|
|
||||||
| `frontend/src/components/AppLogo.vue` | SVG fill gradient update |
|
|
||||||
| `frontend/src/components/AppHeader.vue` | Pill-grouped nav tabs, brand shortening, header gradient, status pulse |
|
|
||||||
| `frontend/src/views/KnowledgeView.vue` | Card type DNA (gradient bars, corner accents), hover bloom, section dividers, empty state text, scroll fades, Fraunces view title |
|
|
||||||
| `frontend/src/components/ChatPanel.vue` | Scroll fade on messages, empty state text |
|
|
||||||
| `frontend/src/views/CalendarView.vue` | Empty state text, amber event times |
|
|
||||||
| `frontend/src/views/BriefingView.vue` | Empty state text, Fraunces greeting |
|
|
||||||
| `frontend/src/views/ChatView.vue` | (uses ChatPanel — inherits changes) |
|
|
||||||
| `frontend/src/App.vue` | Update any global styles referencing old indigo values |
|
|
||||||
|
|
||||||
## What Does NOT Change
|
|
||||||
|
|
||||||
- Overall layout structure (sidebar + content + optional graph panel)
|
|
||||||
- Chat bubble design (user transparent, assistant border-left + shadow)
|
|
||||||
- TipTap editor styling
|
|
||||||
- Settings view layout
|
|
||||||
- Backend — zero changes
|
|
||||||
- Mobile layout patterns
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# Unified Lookup Tool & Wikipedia Integration
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Replace the fragmented `search_web` tool with a single `lookup` tool that checks Wikipedia first and falls back to SearXNG web search. Add Wikipedia as an additional source in the research pipeline. Result: one lightweight tool for factual questions (always available, no config required), and richer research output.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Two changes to the search/knowledge stack:
|
|
||||||
|
|
||||||
1. **New `lookup` tool** replaces `search_web`. Tries Wikipedia REST API summary endpoint first (~200ms, reliable, no config). Falls back to SearXNG + trafilatura article fetch when Wikipedia misses and SearXNG is configured. Always available (no `requires` field).
|
|
||||||
|
|
||||||
2. **Wikipedia sources in research pipeline.** During sub-query execution, `wiki_search` runs alongside `_search_searxng`. Wikipedia articles merge into the source pool and get deduplicated by URL.
|
|
||||||
|
|
||||||
Shared Wikipedia logic lives in a new `wikipedia.py` service module.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### `src/fabledassistant/services/wikipedia.py` (new)
|
|
||||||
|
|
||||||
Two async functions:
|
|
||||||
|
|
||||||
**`wiki_summary(query: str) -> dict | None`**
|
|
||||||
- Direct title lookup via `https://en.wikipedia.org/api/rest_v1/page/summary/{title}`
|
|
||||||
- Returns `{"title": str, "extract": str, "url": str}` on hit
|
|
||||||
- Returns `None` on 404, disambiguation pages (`"type": "disambiguation"`), network errors, or empty extracts
|
|
||||||
- 5-second timeout
|
|
||||||
- User-Agent: `"FabledAssistant/1.0 (https://fabledsword.com)"`
|
|
||||||
|
|
||||||
**`wiki_search(query: str, limit: int = 3) -> list[dict]`**
|
|
||||||
- Search via `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={query}&srlimit={limit}&format=json`
|
|
||||||
- For each search result, fetch its summary via the summary endpoint to get the extract
|
|
||||||
- Returns `[{"title": str, "extract": str, "url": str}, ...]`
|
|
||||||
- Returns `[]` on any failure
|
|
||||||
- Same timeout and User-Agent as above
|
|
||||||
|
|
||||||
### `src/fabledassistant/services/tools/web.py` (modified)
|
|
||||||
|
|
||||||
**Remove:** `search_web_tool`
|
|
||||||
|
|
||||||
**Add:** `lookup_tool`
|
|
||||||
|
|
||||||
```
|
|
||||||
@tool(
|
|
||||||
name="lookup",
|
|
||||||
description="Look up a topic, concept, or factual question. Returns a concise
|
|
||||||
answer from Wikipedia or web sources. Use for definitions,
|
|
||||||
explanations, 'what is X', 'how does Y work'. For comprehensive
|
|
||||||
written reports saved as notes, use research_topic instead.",
|
|
||||||
parameters={
|
|
||||||
"query": {"type": "string", "description": "The topic or question to look up"},
|
|
||||||
},
|
|
||||||
required=["query"],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
No `requires` field — always available.
|
|
||||||
|
|
||||||
**Logic:**
|
|
||||||
1. Call `wiki_summary(query)`
|
|
||||||
2. If Wikipedia returns a result: return `{"success": True, "type": "lookup", "source": "wikipedia", "data": {"title": ..., "extract": ..., "url": ...}}`
|
|
||||||
3. If Wikipedia misses and `Config.searxng_enabled()`:
|
|
||||||
- Call `_search_searxng(query)` to get search results
|
|
||||||
- Fetch top 1-2 result URLs via `_fetch_full_article` (from `rss.py`, trafilatura-based)
|
|
||||||
- Return `{"success": True, "type": "lookup", "source": "web", "data": {"query": ..., "results": [...], "content": ...}}`
|
|
||||||
4. If Wikipedia misses and no SearXNG: return `{"success": True, "type": "lookup", "source": "none", "data": {"query": ..., "message": "No results found. You can answer from your own knowledge."}}`
|
|
||||||
|
|
||||||
### `src/fabledassistant/services/research.py` (modified)
|
|
||||||
|
|
||||||
**In Step 2 (parallel search):**
|
|
||||||
- For each sub-query, run `wiki_search(query, limit=1)` concurrently with `_search_searxng(query)`
|
|
||||||
- Merge Wikipedia results into the per-query result list
|
|
||||||
|
|
||||||
**In Step 3 (deduplication):**
|
|
||||||
- When deduplicating URLs, Wikipedia URLs (`wikipedia.org`) are checked against SearXNG results
|
|
||||||
- If a Wikipedia article URL already appears in SearXNG results, skip the duplicate
|
|
||||||
|
|
||||||
**Wikipedia article content for synthesis:**
|
|
||||||
- The `extract` from `wiki_search` is used as the source content (no additional fetch needed, unlike SearXNG URLs which require `fetch_url_content`)
|
|
||||||
- This means Wikipedia sources are available immediately without an HTTP fetch step
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- All Wikipedia API failures (network, timeout, malformed JSON) return `None`/`[]` silently
|
|
||||||
- `lookup` never raises — always returns a response the model can work with
|
|
||||||
- In the research pipeline, Wikipedia is purely additive; its failure never degrades existing SearXNG-based research
|
|
||||||
- Disambiguation pages are detected via `"type": "disambiguation"` in the summary response and treated as a miss
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### `tests/test_wikipedia.py` (new)
|
|
||||||
|
|
||||||
- `test_wiki_summary_returns_extract` — mock successful summary response, verify return shape
|
|
||||||
- `test_wiki_summary_returns_none_on_404` — mock 404, verify `None`
|
|
||||||
- `test_wiki_summary_returns_none_on_disambiguation` — mock disambiguation response, verify `None`
|
|
||||||
- `test_wiki_search_returns_results` — mock search API + summary fetches, verify list
|
|
||||||
- `test_wiki_search_returns_empty_on_failure` — mock network error, verify `[]`
|
|
||||||
|
|
||||||
### `tests/test_lookup_tool.py` (new)
|
|
||||||
|
|
||||||
- `test_lookup_wikipedia_hit` — mock `wiki_summary` returning data, verify tool returns wikipedia source
|
|
||||||
- `test_lookup_wikipedia_miss_searxng_fallback` — mock `wiki_summary` returning None, SearXNG returning results + article fetch, verify web source
|
|
||||||
- `test_lookup_wikipedia_miss_no_searxng` — mock both missing, verify graceful "no results" response
|
|
||||||
- `test_lookup_always_available` — verify the tool appears in `get_tools_for_user` regardless of SearXNG config
|
|
||||||
|
|
||||||
### `tests/test_research_pipeline.py` (add to existing)
|
|
||||||
|
|
||||||
- `test_research_includes_wikipedia_sources` — mock `wiki_search` alongside SearXNG, verify Wikipedia results appear in source pool
|
|
||||||
|
|
||||||
All tests mock HTTP calls — no live API hits.
|
|
||||||
|
|
||||||
## What Doesn't Change
|
|
||||||
|
|
||||||
- `read_article` tool — stays as-is (explicit URL fetch, different purpose)
|
|
||||||
- `research_topic` tool definition — stays as-is (same name, description, parameters)
|
|
||||||
- `generation_task.py` research interception — stays as-is
|
|
||||||
- `search_images` tool — stays as-is
|
|
||||||
- `_search_searxng` and `_search_searxng_images` — stay as-is
|
|
||||||
- `_fetch_full_article` in `rss.py` — stays as-is, reused by `lookup` for SearXNG fallback
|
|
||||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledassistant-frontend",
|
"name": "scribe-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fabledassistant-frontend",
|
"name": "scribe-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fullcalendar/core": "^6.1.20",
|
"@fullcalendar/core": "^6.1.20",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledassistant-frontend",
|
"name": "scribe-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
case "p": router.push("/projects"); break;
|
case "p": router.push("/projects"); break;
|
||||||
case "r": router.push("/rules"); break;
|
case "r": router.push("/rules"); break;
|
||||||
case "g": router.push("/graph"); break;
|
case "g": router.push("/graph"); break;
|
||||||
case "l": router.push("/calendar"); break;
|
|
||||||
case "x": router.push("/trash"); break;
|
case "x": router.push("/trash"); break;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -190,12 +189,6 @@ onUnmounted(() => {
|
|||||||
<kbd class="shortcut-key">p</kbd>
|
<kbd class="shortcut-key">p</kbd>
|
||||||
<span class="shortcut-desc">Projects</span>
|
<span class="shortcut-desc">Projects</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-row">
|
|
||||||
<kbd class="shortcut-key">g</kbd>
|
|
||||||
<span class="shortcut-key-sep">+</span>
|
|
||||||
<kbd class="shortcut-key">l</kbd>
|
|
||||||
<span class="shortcut-desc">Calendar</span>
|
|
||||||
</div>
|
|
||||||
<div class="shortcut-row">
|
<div class="shortcut-row">
|
||||||
<kbd class="shortcut-key">g</kbd>
|
<kbd class="shortcut-key">g</kbd>
|
||||||
<span class="shortcut-key-sep">+</span>
|
<span class="shortcut-key-sep">+</span>
|
||||||
|
|||||||
@@ -374,72 +374,6 @@ export async function apiStreamPost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Calendar events
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface EventEntry {
|
|
||||||
id: number;
|
|
||||||
uid: string;
|
|
||||||
title: string;
|
|
||||||
start_dt: string;
|
|
||||||
end_dt: string | null;
|
|
||||||
all_day: boolean;
|
|
||||||
description: string;
|
|
||||||
location: string;
|
|
||||||
color: string;
|
|
||||||
recurrence: string | null;
|
|
||||||
caldav_uid: string;
|
|
||||||
project_id: number | null;
|
|
||||||
user_id: number;
|
|
||||||
created_at: string | null;
|
|
||||||
updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventCreatePayload {
|
|
||||||
title: string;
|
|
||||||
start_dt: string;
|
|
||||||
end_dt?: string;
|
|
||||||
all_day?: boolean;
|
|
||||||
description?: string;
|
|
||||||
location?: string;
|
|
||||||
color?: string;
|
|
||||||
recurrence?: string;
|
|
||||||
project_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventUpdatePayload {
|
|
||||||
title?: string;
|
|
||||||
start_dt?: string;
|
|
||||||
end_dt?: string;
|
|
||||||
all_day?: boolean;
|
|
||||||
description?: string;
|
|
||||||
location?: string;
|
|
||||||
color?: string;
|
|
||||||
recurrence?: string | null;
|
|
||||||
project_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listEvents(from: string, to: string): Promise<EventEntry[]> {
|
|
||||||
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
|
|
||||||
return apiPost<EventEntry>('/api/events', payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getEvent(id: number): Promise<EventEntry> {
|
|
||||||
return apiGet<EventEntry>(`/api/events/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
|
|
||||||
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteEvent(id: number): Promise<void> {
|
|
||||||
return apiDelete(`/api/events/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ApiKeyEntry {
|
export interface ApiKeyEntry {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
|
|
||||||
|
export interface System {
|
||||||
|
id: number;
|
||||||
|
project_id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string | null;
|
||||||
|
status: "active" | "archived";
|
||||||
|
order_index: number;
|
||||||
|
open_issue_count: number;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSystems(projectId: number): Promise<System[]> {
|
||||||
|
const data = await apiGet<{ systems: System[] }>(`/api/projects/${projectId}/systems`);
|
||||||
|
return data.systems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSystem(
|
||||||
|
projectId: number,
|
||||||
|
data: { name: string; description?: string; color?: string },
|
||||||
|
): Promise<System> {
|
||||||
|
return apiPost(`/api/projects/${projectId}/systems`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSystem(
|
||||||
|
projectId: number,
|
||||||
|
systemId: number,
|
||||||
|
data: Partial<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string | null;
|
||||||
|
status: "active" | "archived";
|
||||||
|
order_index: number;
|
||||||
|
}>,
|
||||||
|
): Promise<System> {
|
||||||
|
return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSystem(projectId: number, systemId: number): Promise<void> {
|
||||||
|
return apiDelete(`/api/projects/${projectId}/systems/${systemId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight issue shape returned by the project-issues list endpoint.
|
||||||
|
export interface TaskLike {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
systems?: System[];
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectIssues(
|
||||||
|
projectId: number,
|
||||||
|
openOnly = true,
|
||||||
|
): Promise<TaskLike[]> {
|
||||||
|
const data = await apiGet<{ issues: TaskLike[] }>(
|
||||||
|
`/api/projects/${projectId}/issues?open_only=${openOnly}`,
|
||||||
|
);
|
||||||
|
return data.issues;
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ router.afterEach(() => {
|
|||||||
<!-- Left: brand -->
|
<!-- Left: brand -->
|
||||||
<router-link to="/" class="nav-brand">
|
<router-link to="/" class="nav-brand">
|
||||||
<AppLogo :size="34" />
|
<AppLogo :size="34" />
|
||||||
<span class="brand-text">Fabled</span>
|
<span class="brand-text">Scribe</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<!-- Center: primary navigation (desktop) -->
|
<!-- Center: primary navigation (desktop) -->
|
||||||
@@ -47,7 +47,6 @@ router.afterEach(() => {
|
|||||||
<div class="nav-pill-bar">
|
<div class="nav-pill-bar">
|
||||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +92,6 @@ router.afterEach(() => {
|
|||||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
|
|||||||
@@ -1,677 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Trash2, X } from "lucide-vue-next";
|
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
|
||||||
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
|
||||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
|
||||||
import { useToastStore } from "@/stores/toast";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
// null = create mode; EventEntry = edit mode
|
|
||||||
event: EventEntry | null;
|
|
||||||
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
|
|
||||||
initialDate?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "close"): void;
|
|
||||||
(e: "created", event: EventEntry): void;
|
|
||||||
(e: "updated", event: EventEntry): void;
|
|
||||||
(e: "deleted", id: number): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const toast = useToastStore();
|
|
||||||
|
|
||||||
const isEditMode = computed(() => !!props.event);
|
|
||||||
const saving = ref(false);
|
|
||||||
const deleting = ref(false);
|
|
||||||
const deleteConfirm = ref(false);
|
|
||||||
|
|
||||||
// Form fields
|
|
||||||
const title = ref("");
|
|
||||||
const startDate = ref("");
|
|
||||||
const startTime = ref("");
|
|
||||||
const endDate = ref("");
|
|
||||||
const endTime = ref("");
|
|
||||||
const allDay = ref(false);
|
|
||||||
const description = ref("");
|
|
||||||
const location = ref("");
|
|
||||||
const color = ref("");
|
|
||||||
const projectId = ref<number | null>(null);
|
|
||||||
const recurrence = ref<string>("");
|
|
||||||
|
|
||||||
// Preset RRULE strings. The select binds to `recurrencePreset`, which writes
|
|
||||||
// through to `recurrence`. CalDAV-imported rules with extra parts
|
|
||||||
// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw
|
|
||||||
// string is shown read-only below the select.
|
|
||||||
const RECURRENCE_PRESETS: Record<string, string> = {
|
|
||||||
none: "",
|
|
||||||
daily: "FREQ=DAILY",
|
|
||||||
weekly: "FREQ=WEEKLY",
|
|
||||||
monthly: "FREQ=MONTHLY",
|
|
||||||
yearly: "FREQ=YEARLY",
|
|
||||||
};
|
|
||||||
|
|
||||||
const recurrencePreset = computed<string>({
|
|
||||||
get() {
|
|
||||||
const r = (recurrence.value || "").trim();
|
|
||||||
if (!r) return "none";
|
|
||||||
for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) {
|
|
||||||
if (val && val === r) return key;
|
|
||||||
}
|
|
||||||
return "custom";
|
|
||||||
},
|
|
||||||
set(key: string) {
|
|
||||||
if (key === "custom") return; // no-op; can't pick custom from dropdown
|
|
||||||
recurrence.value = RECURRENCE_PRESETS[key] ?? "";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isCustomRecurrence = computed(() => recurrencePreset.value === "custom");
|
|
||||||
|
|
||||||
function dateFromIso(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (isNaN(d.getTime())) return iso.slice(0, 10);
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeFromIso(iso: string): string {
|
|
||||||
if (!iso.includes("T")) return "09:00";
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (isNaN(d.getTime())) return iso.slice(11, 16);
|
|
||||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIso(date: string, time: string): string {
|
|
||||||
if (!time) return `${date}T00:00:00`;
|
|
||||||
// Include local timezone offset so the server stores the correct UTC time
|
|
||||||
const local = new Date(`${date}T${time}:00`);
|
|
||||||
const off = -local.getTimezoneOffset();
|
|
||||||
const sign = off >= 0 ? "+" : "-";
|
|
||||||
const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0");
|
|
||||||
const min = String(Math.abs(off) % 60).padStart(2, "0");
|
|
||||||
return `${date}T${time}:00${sign}${h}:${min}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Round up to next 30-minute boundary */
|
|
||||||
function nextRoundedTime(): string {
|
|
||||||
const now = new Date();
|
|
||||||
let h = now.getHours();
|
|
||||||
let m = now.getMinutes();
|
|
||||||
if (m <= 30) { m = 30; }
|
|
||||||
else { m = 0; h = (h + 1) % 24; }
|
|
||||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add hours to a time string (HH:MM), returns HH:MM */
|
|
||||||
function addHours(time: string, hours: number): string {
|
|
||||||
const [h, m] = time.split(":").map(Number);
|
|
||||||
const totalMin = h * 60 + m + hours * 60;
|
|
||||||
const nh = Math.floor(totalMin / 60) % 24;
|
|
||||||
const nm = totalMin % 60;
|
|
||||||
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Duration in minutes between two time strings */
|
|
||||||
function durationMin(start: string, end: string): number {
|
|
||||||
const [sh, sm] = start.split(":").map(Number);
|
|
||||||
const [eh, em] = end.split(":").map(Number);
|
|
||||||
return (eh * 60 + em) - (sh * 60 + sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if a date+time is in the past */
|
|
||||||
const isPastEvent = computed(() => {
|
|
||||||
if (allDay.value || !startDate.value || !startTime.value) return false;
|
|
||||||
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
|
|
||||||
return dt.getTime() < Date.now();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track duration so end moves with start
|
|
||||||
let _lastDurationMin = 60;
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
if (props.event) {
|
|
||||||
title.value = props.event.title;
|
|
||||||
allDay.value = props.event.all_day;
|
|
||||||
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
|
|
||||||
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
|
|
||||||
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
|
|
||||||
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
|
|
||||||
description.value = props.event.description || "";
|
|
||||||
location.value = props.event.location || "";
|
|
||||||
color.value = props.event.color || "";
|
|
||||||
projectId.value = props.event.project_id;
|
|
||||||
recurrence.value = props.event.recurrence || "";
|
|
||||||
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
|
||||||
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
|
||||||
} else {
|
|
||||||
title.value = "";
|
|
||||||
allDay.value = false;
|
|
||||||
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
|
||||||
const roundedStart = nextRoundedTime();
|
|
||||||
startDate.value = base;
|
|
||||||
startTime.value = roundedStart;
|
|
||||||
endDate.value = base;
|
|
||||||
endTime.value = addHours(roundedStart, 1);
|
|
||||||
description.value = "";
|
|
||||||
location.value = "";
|
|
||||||
color.value = "";
|
|
||||||
projectId.value = null;
|
|
||||||
recurrence.value = "";
|
|
||||||
_lastDurationMin = 60;
|
|
||||||
}
|
|
||||||
deleteConfirm.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// When start time changes, move end time to preserve duration
|
|
||||||
watch(startTime, (newStart) => {
|
|
||||||
if (allDay.value || !newStart) return;
|
|
||||||
endTime.value = addHours(newStart, _lastDurationMin / 60);
|
|
||||||
});
|
|
||||||
|
|
||||||
// When start date changes, move end date to match (preserve same-day or multi-day gap)
|
|
||||||
watch(startDate, (newDate) => {
|
|
||||||
if (!newDate) return;
|
|
||||||
endDate.value = newDate;
|
|
||||||
});
|
|
||||||
|
|
||||||
// When end time changes manually, update the tracked duration (but guard against end < start)
|
|
||||||
watch(endTime, (newEnd) => {
|
|
||||||
if (allDay.value || !newEnd || !startTime.value) return;
|
|
||||||
const dur = durationMin(startTime.value, newEnd);
|
|
||||||
if (dur <= 0) {
|
|
||||||
// Snap back to start + 1 hour
|
|
||||||
endTime.value = addHours(startTime.value, 1);
|
|
||||||
_lastDurationMin = 60;
|
|
||||||
} else {
|
|
||||||
_lastDurationMin = dur;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// All-day toggle: clear/restore times
|
|
||||||
watch(allDay, (isAllDay) => {
|
|
||||||
if (isAllDay) {
|
|
||||||
startTime.value = "";
|
|
||||||
endTime.value = "";
|
|
||||||
} else {
|
|
||||||
const rounded = nextRoundedTime();
|
|
||||||
startTime.value = rounded;
|
|
||||||
endTime.value = addHours(rounded, 1);
|
|
||||||
_lastDurationMin = 60;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(() => props.event, resetForm, { immediate: true });
|
|
||||||
watch(() => props.initialDate, resetForm);
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
if (deleteConfirm.value) {
|
|
||||||
// Esc cancels the delete-confirm rather than closing the modal —
|
|
||||||
// gives the user a clear way out of the destructive prompt.
|
|
||||||
deleteConfirm.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
attemptClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
|
||||||
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
|
||||||
|
|
||||||
// ── Close / save flow ─────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
|
|
||||||
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
|
|
||||||
//
|
|
||||||
// Validity-aware behavior:
|
|
||||||
// - Form valid → save (PATCH for edit, POST for create), then close.
|
|
||||||
// - Form invalid in EDIT mode → discard the in-memory change and close.
|
|
||||||
// A toast tells the user what happened so they don't think their edit
|
|
||||||
// silently landed.
|
|
||||||
// - Form invalid in CREATE mode → close silently (nothing existed to begin
|
|
||||||
// with; no need to call this out).
|
|
||||||
|
|
||||||
function isFormValid(): { valid: boolean; reason?: string } {
|
|
||||||
if (!title.value.trim()) {
|
|
||||||
return { valid: false, reason: "Title required" };
|
|
||||||
}
|
|
||||||
if (!startDate.value) {
|
|
||||||
return { valid: false, reason: "Start date required" };
|
|
||||||
}
|
|
||||||
if (!allDay.value && !startTime.value) {
|
|
||||||
return { valid: false, reason: "Start time required" };
|
|
||||||
}
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
let _closing = false;
|
|
||||||
|
|
||||||
async function attemptClose() {
|
|
||||||
if (_closing) return;
|
|
||||||
_closing = true;
|
|
||||||
try {
|
|
||||||
const validity = isFormValid();
|
|
||||||
if (!validity.valid) {
|
|
||||||
if (isEditMode.value) {
|
|
||||||
toast.show(`${validity.reason} — change discarded`, "warning");
|
|
||||||
}
|
|
||||||
// Create mode + invalid: silent close. Nothing was committed.
|
|
||||||
emit("close");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await save();
|
|
||||||
emit("close");
|
|
||||||
} finally {
|
|
||||||
_closing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
|
||||||
const end_dt = endDate.value
|
|
||||||
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
if (isEditMode.value && props.event) {
|
|
||||||
const payload: EventUpdatePayload = {
|
|
||||||
title: title.value.trim(),
|
|
||||||
start_dt,
|
|
||||||
end_dt,
|
|
||||||
all_day: allDay.value,
|
|
||||||
description: description.value,
|
|
||||||
location: location.value,
|
|
||||||
color: color.value,
|
|
||||||
project_id: projectId.value ?? undefined,
|
|
||||||
recurrence: recurrence.value || null,
|
|
||||||
};
|
|
||||||
const updated = await updateEvent(props.event.id, payload);
|
|
||||||
emit("updated", updated);
|
|
||||||
} else {
|
|
||||||
const payload: EventCreatePayload = {
|
|
||||||
title: title.value.trim(),
|
|
||||||
start_dt,
|
|
||||||
end_dt,
|
|
||||||
all_day: allDay.value,
|
|
||||||
description: description.value,
|
|
||||||
location: location.value,
|
|
||||||
color: color.value,
|
|
||||||
project_id: projectId.value ?? undefined,
|
|
||||||
recurrence: recurrence.value || undefined,
|
|
||||||
};
|
|
||||||
const created = await createEvent(payload);
|
|
||||||
emit("created", created);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to save event", "error");
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doDelete() {
|
|
||||||
if (!props.event) return;
|
|
||||||
deleting.value = true;
|
|
||||||
try {
|
|
||||||
await deleteEvent(props.event.id);
|
|
||||||
toast.show("Event deleted", "success");
|
|
||||||
emit("deleted", props.event.id);
|
|
||||||
} catch {
|
|
||||||
toast.show("Failed to delete event", "error");
|
|
||||||
deleting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div class="modal-backdrop" @click.self="attemptClose">
|
|
||||||
<div class="modal-panel" role="dialog" aria-modal="true">
|
|
||||||
<!-- Header: trash + close (or inline delete-confirm) -->
|
|
||||||
<div class="modal-header">
|
|
||||||
<template v-if="!deleteConfirm">
|
|
||||||
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button
|
|
||||||
v-if="isEditMode"
|
|
||||||
class="header-btn header-btn-danger"
|
|
||||||
@click="deleteConfirm = true"
|
|
||||||
title="Delete event"
|
|
||||||
aria-label="Delete event"
|
|
||||||
><Trash2 :size="16" /></button>
|
|
||||||
<button
|
|
||||||
class="header-btn"
|
|
||||||
@click="attemptClose"
|
|
||||||
title="Close"
|
|
||||||
aria-label="Close"
|
|
||||||
><X :size="16" /></button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="delete-confirm-prompt">Delete this event?</span>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger"
|
|
||||||
:disabled="deleting"
|
|
||||||
@click="doDelete"
|
|
||||||
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-confirm-cancel"
|
|
||||||
@click="deleteConfirm = false"
|
|
||||||
>No</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body: form (scrolls if it gets long) -->
|
|
||||||
<form class="modal-form" @submit.prevent="attemptClose">
|
|
||||||
<!-- Title -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Title <span class="required">*</span></label>
|
|
||||||
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- All-day toggle -->
|
|
||||||
<div class="form-field form-field-row">
|
|
||||||
<label class="form-label form-label-inline">All day</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="['toggle-btn', { active: allDay }]"
|
|
||||||
@click="allDay = !allDay"
|
|
||||||
>{{ allDay ? "Yes" : "No" }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Start -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Start <span class="required">*</span></label>
|
|
||||||
<div class="dt-row">
|
|
||||||
<input v-model="startDate" type="date" class="form-input dt-date" required />
|
|
||||||
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
|
|
||||||
</div>
|
|
||||||
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- End -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">End</label>
|
|
||||||
<div class="dt-row">
|
|
||||||
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
|
|
||||||
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recurrence -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Repeat</label>
|
|
||||||
<select v-model="recurrencePreset" class="form-input">
|
|
||||||
<option value="none">Does not repeat</option>
|
|
||||||
<option value="daily">Daily</option>
|
|
||||||
<option value="weekly">Weekly</option>
|
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
<option value="yearly">Yearly</option>
|
|
||||||
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
|
|
||||||
Custom rule: <code>{{ recurrence }}</code>
|
|
||||||
<br />
|
|
||||||
<span class="form-hint">Picking a preset will replace this rule.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Location -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
|
|
||||||
<input v-model="location" class="form-input" placeholder="Location" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
|
|
||||||
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Color -->
|
|
||||||
<div class="form-field form-field-row">
|
|
||||||
<label class="form-label form-label-inline">Color</label>
|
|
||||||
<div class="color-row">
|
|
||||||
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
|
||||||
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
|
|
||||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Project -->
|
|
||||||
<div class="form-field">
|
|
||||||
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
|
|
||||||
<ProjectSelector v-model="projectId" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
|
|
||||||
matching the no-explicit-Save-button intent: Enter commits. -->
|
|
||||||
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-panel {
|
|
||||||
background: var(--color-surface, #1a1b1e);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: 12px;
|
|
||||||
width: min(480px, 100%);
|
|
||||||
max-height: calc(100vh - 2.5rem);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.85rem 1rem 0.85rem 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--color-border, #2a2b30);
|
|
||||||
background: var(--color-surface, #1a1b1e);
|
|
||||||
min-height: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.4rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
line-height: 1;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.header-btn:hover {
|
|
||||||
background: var(--color-hover, rgba(255,255,255,0.06));
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
|
|
||||||
than Save would have been — destructive actions shouldn't loom. */
|
|
||||||
.header-btn-danger:hover {
|
|
||||||
background: var(--color-action-destructive);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline delete-confirm prompt replaces the title row */
|
|
||||||
.delete-confirm-prompt {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form scrolls inside the panel when content overflows */
|
|
||||||
.modal-form {
|
|
||||||
padding: 1.25rem 1.5rem 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.1rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
||||||
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.form-label-inline { flex-shrink: 0; margin: 0; }
|
|
||||||
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
|
||||||
|
|
||||||
.required { color: #f87171; }
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
background: var(--color-input-bg, #111113);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.5rem 0.65rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.form-input:focus { outline: none; border-color: var(--color-primary); }
|
|
||||||
|
|
||||||
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
|
||||||
|
|
||||||
.dt-row { display: flex; gap: 0.5rem; }
|
|
||||||
.dt-date { flex: 1; }
|
|
||||||
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
|
||||||
.form-past-hint {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.recurrence-custom-hint {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.recurrence-custom-hint code {
|
|
||||||
background: var(--color-input-bg, #111113);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
font-family: var(--font-mono, ui-monospace, monospace);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
background: var(--color-input-bg, #111113);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.3rem 0.9rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.toggle-btn.active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
|
|
||||||
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
|
|
||||||
.color-hex { flex: 1; }
|
|
||||||
.btn-clear-color {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.2rem 0.3rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Confirm-delete buttons (only shown during the inline confirm flow) */
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--color-action-destructive);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
|
||||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.btn-confirm-cancel {
|
|
||||||
background: var(--color-action-secondary);
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
|
|
||||||
|
|
||||||
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
|
|
||||||
path as X / Esc / backdrop. No visible Save button needed. */
|
|
||||||
.hidden-submit {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0,0,0,0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
|
import { useSystemsStore } from "@/stores/systems";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
import { getProjectIssues } from "@/api/systems";
|
||||||
|
import type { System, TaskLike } from "@/api/systems";
|
||||||
|
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
||||||
|
|
||||||
|
const props = defineProps<{ projectId: number }>();
|
||||||
|
|
||||||
|
const store = useSystemsStore();
|
||||||
|
const toast = useToastStore();
|
||||||
|
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const showArchived = ref(false);
|
||||||
|
const issues = ref<TaskLike[]>([]);
|
||||||
|
|
||||||
|
// Create state
|
||||||
|
const showCreate = ref(false);
|
||||||
|
const newName = ref("");
|
||||||
|
const newDescription = ref("");
|
||||||
|
const creating = ref(false);
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
const editingId = ref<number | null>(null);
|
||||||
|
const editName = ref("");
|
||||||
|
const editDescription = ref("");
|
||||||
|
const savingEdit = ref(false);
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
const deletingSystem = ref<System | null>(null);
|
||||||
|
|
||||||
|
const systems = computed<System[]>(() => store.systemsByProject[props.projectId] ?? []);
|
||||||
|
const activeSystems = computed(() => systems.value.filter((s) => s.status === "active"));
|
||||||
|
const archivedSystems = computed(() => systems.value.filter((s) => s.status === "archived"));
|
||||||
|
const visibleSystems = computed(() =>
|
||||||
|
showArchived.value ? systems.value : activeSystems.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await store.fetchSystems(props.projectId);
|
||||||
|
} catch {
|
||||||
|
error.value = "Failed to load systems.";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
issues.value = await getProjectIssues(props.projectId);
|
||||||
|
} catch {
|
||||||
|
issues.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(() => props.projectId, load);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
showCreate.value = true;
|
||||||
|
newName.value = "";
|
||||||
|
newDescription.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCreate() {
|
||||||
|
showCreate.value = false;
|
||||||
|
newName.value = "";
|
||||||
|
newDescription.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
const name = newName.value.trim();
|
||||||
|
if (!name || creating.value) return;
|
||||||
|
creating.value = true;
|
||||||
|
try {
|
||||||
|
await store.createSystem(props.projectId, {
|
||||||
|
name,
|
||||||
|
description: newDescription.value.trim() || undefined,
|
||||||
|
});
|
||||||
|
cancelCreate();
|
||||||
|
toast.show("System created");
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to create system", "error");
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(system: System) {
|
||||||
|
editingId.value = system.id;
|
||||||
|
editName.value = system.name;
|
||||||
|
editDescription.value = system.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit(system: System) {
|
||||||
|
const name = editName.value.trim();
|
||||||
|
if (!name || savingEdit.value) return;
|
||||||
|
savingEdit.value = true;
|
||||||
|
try {
|
||||||
|
await store.updateSystem(props.projectId, system.id, {
|
||||||
|
name,
|
||||||
|
description: editDescription.value.trim(),
|
||||||
|
});
|
||||||
|
editingId.value = null;
|
||||||
|
toast.show("System updated");
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to update system", "error");
|
||||||
|
} finally {
|
||||||
|
savingEdit.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archive(system: System) {
|
||||||
|
try {
|
||||||
|
await store.archiveSystem(props.projectId, system.id);
|
||||||
|
toast.show("System archived");
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to archive system", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unarchive(system: System) {
|
||||||
|
try {
|
||||||
|
await store.unarchiveSystem(props.projectId, system.id);
|
||||||
|
toast.show("System restored");
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to restore system", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const system = deletingSystem.value;
|
||||||
|
if (!system) return;
|
||||||
|
deletingSystem.value = null;
|
||||||
|
try {
|
||||||
|
await store.deleteSystem(props.projectId, system.id);
|
||||||
|
toast.show("System deleted");
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to delete system", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="systems-section">
|
||||||
|
<!-- Open issues -->
|
||||||
|
<div v-if="issues.length" class="open-issues">
|
||||||
|
<div class="open-issues-label">⚠ Open issues ({{ issues.length }})</div>
|
||||||
|
<ul class="issue-list">
|
||||||
|
<li v-for="issue in issues" :key="issue.id" class="issue-item">
|
||||||
|
<router-link :to="`/tasks/${issue.id}`" class="issue-link">
|
||||||
|
<span class="issue-mark" :class="`imk-${issue.status}`">{{ issue.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||||
|
<span class="issue-name">{{ issue.title }}</span>
|
||||||
|
<span v-if="issue.systems && issue.systems.length" class="issue-systems">
|
||||||
|
<span v-for="s in issue.systems" :key="s.id" class="issue-sys-chip">{{ s.name }}</span>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="systems-toolbar">
|
||||||
|
<button v-if="!showCreate" class="btn-add-system" @click="openCreate">
|
||||||
|
+ System
|
||||||
|
</button>
|
||||||
|
<label v-if="archivedSystems.length" class="archived-toggle">
|
||||||
|
<input v-model="showArchived" type="checkbox" class="archived-checkbox" />
|
||||||
|
Show archived ({{ archivedSystems.length }})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create form -->
|
||||||
|
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
|
||||||
|
<input
|
||||||
|
v-model="newName"
|
||||||
|
class="system-input"
|
||||||
|
placeholder="System name"
|
||||||
|
aria-label="System name"
|
||||||
|
autofocus
|
||||||
|
@keydown.escape="cancelCreate"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-model="newDescription"
|
||||||
|
class="system-textarea"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What is this subsystem responsible for? (optional)"
|
||||||
|
aria-label="System description"
|
||||||
|
></textarea>
|
||||||
|
<div class="system-form-actions">
|
||||||
|
<button type="submit" class="btn-confirm" :disabled="!newName.trim() || creating">
|
||||||
|
{{ creating ? "Creating…" : "Create" }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-cancel" @click="cancelCreate">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.loading && !systems.length" class="systems-skeleton" aria-label="Loading systems">
|
||||||
|
<div class="skel-row"></div>
|
||||||
|
<div class="skel-row skel-row--short"></div>
|
||||||
|
<div class="skel-row"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else-if="!visibleSystems.length" class="systems-empty">
|
||||||
|
<p class="empty-title">No systems yet</p>
|
||||||
|
<p class="empty-sub">Define a reusable subsystem or area to organize issues against.</p>
|
||||||
|
<button v-if="!showCreate" class="btn-confirm" @click="openCreate">+ Create a system</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<ul v-else class="systems-list">
|
||||||
|
<li
|
||||||
|
v-for="system in visibleSystems"
|
||||||
|
:key="system.id"
|
||||||
|
class="system-card"
|
||||||
|
:class="{ 'system-card--archived': system.status === 'archived' }"
|
||||||
|
>
|
||||||
|
<!-- Inline edit -->
|
||||||
|
<template v-if="editingId === system.id">
|
||||||
|
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
|
||||||
|
<input
|
||||||
|
v-model="editName"
|
||||||
|
class="system-input"
|
||||||
|
placeholder="System name"
|
||||||
|
aria-label="System name"
|
||||||
|
autofocus
|
||||||
|
@keydown.escape="cancelEdit"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-model="editDescription"
|
||||||
|
class="system-textarea"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
aria-label="System description"
|
||||||
|
></textarea>
|
||||||
|
<div class="system-form-actions">
|
||||||
|
<button type="submit" class="btn-confirm" :disabled="!editName.trim() || savingEdit">
|
||||||
|
{{ savingEdit ? "Saving…" : "Save" }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-cancel" @click="cancelEdit">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Display -->
|
||||||
|
<template v-else>
|
||||||
|
<span
|
||||||
|
class="system-swatch"
|
||||||
|
:style="{ background: system.color || 'var(--color-text-muted)' }"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<div class="system-body">
|
||||||
|
<div class="system-name-row">
|
||||||
|
<span class="system-name">{{ system.name }}</span>
|
||||||
|
<span
|
||||||
|
class="issue-badge"
|
||||||
|
:title="`${system.open_issue_count} open issue(s)`"
|
||||||
|
>{{ system.open_issue_count }} open</span>
|
||||||
|
<span v-if="system.status === 'archived'" class="archived-badge">Archived</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="system-actions">
|
||||||
|
<button class="action-btn" title="Edit" aria-label="Edit system" @click="startEdit(system)">
|
||||||
|
<Pencil :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="system.status === 'active'"
|
||||||
|
class="action-btn"
|
||||||
|
title="Archive"
|
||||||
|
aria-label="Archive system"
|
||||||
|
@click="archive(system)"
|
||||||
|
>
|
||||||
|
<Archive :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="action-btn"
|
||||||
|
title="Restore"
|
||||||
|
aria-label="Restore system"
|
||||||
|
@click="unarchive(system)"
|
||||||
|
>
|
||||||
|
<ArchiveRestore :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-delete"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete system"
|
||||||
|
@click="deletingSystem = system"
|
||||||
|
>
|
||||||
|
<Trash2 :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
<teleport to="body">
|
||||||
|
<div v-if="deletingSystem" class="modal-overlay" @click.self="deletingSystem = null">
|
||||||
|
<div class="modal-card">
|
||||||
|
<h3 class="modal-title">Delete System</h3>
|
||||||
|
<p class="modal-message">
|
||||||
|
Delete <strong>{{ deletingSystem.name }}</strong>? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="modal-btn" @click="deletingSystem = null">Cancel</button>
|
||||||
|
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.systems-section { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
|
||||||
|
/* ── Open issues ──────────────────────────────────────────────── */
|
||||||
|
.open-issues { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-muted); }
|
||||||
|
.issue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
|
||||||
|
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--radius-sm); text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||||
|
.issue-link:hover { background: var(--color-bg-secondary); }
|
||||||
|
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
|
||||||
|
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
||||||
|
.issue-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||||
|
.issue-sys-chip { font-size: 0.66rem; color: var(--color-text-secondary); background: var(--color-bg-secondary); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||||
|
|
||||||
|
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||||
|
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||||
|
.btn-add-system {
|
||||||
|
background: none;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: 0.28rem 0.65rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-add-system:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
.btn-add-system:focus-visible { outline: none; border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
|
||||||
|
.archived-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.archived-checkbox { accent-color: var(--color-primary); cursor: pointer; }
|
||||||
|
|
||||||
|
/* ── Create / edit form ───────────────────────────────────────── */
|
||||||
|
.system-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
||||||
|
.system-input, .system-textarea {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--color-primary); }
|
||||||
|
.system-textarea { resize: vertical; }
|
||||||
|
|
||||||
|
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||||
|
.btn-confirm {
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
background: var(--color-action-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||||
|
.btn-confirm:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
||||||
|
.btn-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
background: var(--color-action-secondary);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||||
|
.btn-cancel:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ── List ─────────────────────────────────────────────────────── */
|
||||||
|
.systems-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.system-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
|
transition: border-color 0.12s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.system-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
||||||
|
box-shadow: 0 3px 10px rgba(0,0,0,0.07);
|
||||||
|
}
|
||||||
|
.system-card--archived { opacity: 0.6; }
|
||||||
|
|
||||||
|
.system-swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
.system-body { flex: 1; min-width: 0; }
|
||||||
|
.system-name-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.system-name { font-weight: 500; color: var(--color-text); word-break: break-word; }
|
||||||
|
.issue-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.archived-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
}
|
||||||
|
.system-description {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-actions { display: flex; gap: 0.15rem; flex-shrink: 0; opacity: 0; transition: opacity 0.15s; }
|
||||||
|
.system-card:hover .system-actions,
|
||||||
|
.system-card:focus-within .system-actions { opacity: 1; }
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
|
||||||
|
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
|
||||||
|
.action-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||||
|
|
||||||
|
/* ── Empty ────────────────────────────────────────────────────── */
|
||||||
|
.systems-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.empty-title { margin: 0; font-weight: 500; color: var(--color-text); }
|
||||||
|
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-muted); max-width: 32ch; }
|
||||||
|
|
||||||
|
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* ── Skeleton ─────────────────────────────────────────────────── */
|
||||||
|
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||||
|
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.skel-row {
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-bg-secondary) 25%,
|
||||||
|
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
|
||||||
|
var(--color-bg-secondary) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skel-shine 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
.skel-row--short { width: 65%; }
|
||||||
|
|
||||||
|
/* ── Modal ────────────────────────────────────────────────────── */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: var(--color-overlay, rgba(0,0,0,0.45));
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 8px 32px var(--color-shadow);
|
||||||
|
}
|
||||||
|
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
||||||
|
.modal-message { font-size: 0.9rem; color: var(--color-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
|
||||||
|
.modal-btn {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.modal-btn:hover { background: var(--color-bg); }
|
||||||
|
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
|
||||||
|
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
||||||
|
</style>
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
interface ForecastDay {
|
|
||||||
day: string
|
|
||||||
condition: string
|
|
||||||
high: number
|
|
||||||
low: number
|
|
||||||
precip_probability: number | null
|
|
||||||
precip_mm: number | null
|
|
||||||
windspeed_max: number
|
|
||||||
precip_summary?: string
|
|
||||||
precip_peak_hour?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WeatherData {
|
|
||||||
location: string
|
|
||||||
fetched_at: string
|
|
||||||
current_temp: number
|
|
||||||
condition: string
|
|
||||||
today_high: number | null
|
|
||||||
today_low: number | null
|
|
||||||
yesterday_high: number | null
|
|
||||||
yesterday_low: number | null
|
|
||||||
wind_unit?: string
|
|
||||||
precip_summary?: string | null
|
|
||||||
forecast: ForecastDay[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
weather: WeatherData | null
|
|
||||||
tempUnit?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function weatherIcon(condition: string): string {
|
|
||||||
const c = condition.toLowerCase()
|
|
||||||
if (c.includes('thunderstorm')) return '⛈️'
|
|
||||||
if (c.includes('hail')) return '🌨️'
|
|
||||||
if (c.includes('snow showers')) return '🌨️'
|
|
||||||
if (c.includes('snow')) return '❄️'
|
|
||||||
if (c.includes('rain showers: violent')) return '⛈️'
|
|
||||||
if (c.includes('rain showers')) return '🌦️'
|
|
||||||
if (c.includes('drizzle') || c.includes('rain')) return '🌧️'
|
|
||||||
if (c.includes('fog')) return '🌫️'
|
|
||||||
if (c.includes('overcast')) return '☁️'
|
|
||||||
if (c.includes('partly cloudy')) return '⛅'
|
|
||||||
if (c.includes('mainly clear')) return '🌤️'
|
|
||||||
if (c.includes('clear')) return '☀️'
|
|
||||||
return '🌡️'
|
|
||||||
}
|
|
||||||
|
|
||||||
const unit = computed(() => props.tempUnit ?? 'C')
|
|
||||||
|
|
||||||
const tempDelta = computed(() => {
|
|
||||||
const w = props.weather
|
|
||||||
if (!w || w.today_high == null || w.yesterday_high == null) return null
|
|
||||||
const diff = w.today_high - w.yesterday_high
|
|
||||||
if (Math.abs(diff) < 1) return 'Same as yesterday'
|
|
||||||
const dir = diff > 0 ? 'warmer' : 'cooler'
|
|
||||||
return `${Math.abs(diff)}° ${dir} than yesterday`
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchedAtLabel = computed(() => {
|
|
||||||
if (!props.weather?.fetched_at) return ''
|
|
||||||
try {
|
|
||||||
return new Date(props.weather.fetched_at).toLocaleTimeString([], {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function hasPrecip(day: ForecastDay): boolean {
|
|
||||||
return (day.precip_probability != null && day.precip_probability > 0) ||
|
|
||||||
(day.precip_mm != null && day.precip_mm > 0)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="weather" class="weather-card">
|
|
||||||
<div class="weather-header">
|
|
||||||
<span class="weather-location">{{ weather.location }}</span>
|
|
||||||
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="weather-current">
|
|
||||||
<span class="weather-icon">{{ weatherIcon(weather.condition) }}</span>
|
|
||||||
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
|
|
||||||
<span class="weather-condition">{{ weather.condition }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="weather-today" v-if="weather.today_high != null">
|
|
||||||
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
|
||||||
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="weather.precip_summary" class="weather-precip-summary">
|
|
||||||
💧 {{ weather.precip_summary }}
|
|
||||||
</div>
|
|
||||||
<table class="weather-forecast" v-if="weather.forecast.length">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th></th>
|
|
||||||
<th>Hi / Lo</th>
|
|
||||||
<th>💧</th>
|
|
||||||
<th>💨 {{ weather.wind_unit ?? 'km/h' }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="day in weather.forecast" :key="day.day">
|
|
||||||
<td class="forecast-day-name">{{ day.day }}</td>
|
|
||||||
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
|
|
||||||
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
|
|
||||||
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
|
|
||||||
<template v-if="day.precip_summary">
|
|
||||||
<span class="precip-detail" :title="day.precip_summary">
|
|
||||||
{{ day.precip_probability }}%
|
|
||||||
<span v-if="day.precip_peak_hour" class="precip-peak">{{ day.precip_peak_hour }}</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
|
|
||||||
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
|
|
||||||
<template v-else>—</template>
|
|
||||||
</td>
|
|
||||||
<td class="forecast-wind">{{ day.windspeed_max }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div v-else class="weather-card weather-unavailable">
|
|
||||||
Weather data unavailable — will retry at next slot.
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.weather-card {
|
|
||||||
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
container-type: inline-size;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-location {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-fetched-at {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-current {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-icon {
|
|
||||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-temp {
|
|
||||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-condition {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-today {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-delta {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-precip-summary {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.83rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
|
||||||
border-radius: var(--radius-sm, 6px);
|
|
||||||
border-left: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-forecast {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-forecast thead th {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-align: right;
|
|
||||||
padding: 0.5rem 0.4rem 0.25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-forecast thead th:first-child,
|
|
||||||
.weather-forecast thead th:nth-child(2) {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-forecast tbody td {
|
|
||||||
padding: 0.3rem 0.4rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-day-name {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-icon {
|
|
||||||
font-size: clamp(0.9rem, 3cqi, 1.3rem);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-temps {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-precip {
|
|
||||||
text-align: right;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-precip--dry {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.precip-detail {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.precip-peak {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.forecast-wind {
|
|
||||||
text-align: right;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.weather-unavailable {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -108,11 +108,6 @@ const router = createRouter({
|
|||||||
name: "shared-with-me",
|
name: "shared-with-me",
|
||||||
component: () => import("@/views/SharedWithMeView.vue"),
|
component: () => import("@/views/SharedWithMeView.vue"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/calendar",
|
|
||||||
name: "calendar",
|
|
||||||
component: () => import("@/views/CalendarView.vue"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
name: "settings",
|
name: "settings",
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
project_id?: number | null;
|
project_id?: number | null;
|
||||||
milestone_id?: number | null;
|
milestone_id?: number | null;
|
||||||
note_type?: string;
|
note_type?: string;
|
||||||
metadata?: Record<string, string> | null;
|
|
||||||
}): Promise<Note> {
|
}): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
return await apiPost<Note>("/api/notes", data);
|
return await apiPost<Note>("/api/notes", data);
|
||||||
@@ -43,7 +42,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
|
|
||||||
async function updateNote(
|
async function updateNote(
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type" | "metadata">>
|
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type">>
|
||||||
): Promise<Note> {
|
): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ref } from "vue";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import * as api from "@/api/systems";
|
||||||
|
import type { System } from "@/api/systems";
|
||||||
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
|
export const useSystemsStore = defineStore("systems", () => {
|
||||||
|
const systemsByProject = ref<Record<number, System[]>>({});
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function fetchSystems(projectId: number) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
systemsByProject.value[projectId] = await api.listSystems(projectId);
|
||||||
|
} catch (e) {
|
||||||
|
useToastStore().show("Failed to load systems", "error");
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSystem(
|
||||||
|
projectId: number,
|
||||||
|
data: { name: string; description?: string; color?: string },
|
||||||
|
) {
|
||||||
|
const system = await api.createSystem(projectId, data);
|
||||||
|
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
||||||
|
systemsByProject.value[projectId].push(system);
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSystem(
|
||||||
|
projectId: number,
|
||||||
|
systemId: number,
|
||||||
|
data: Partial<Pick<System, "name" | "description" | "color" | "status" | "order_index">>,
|
||||||
|
) {
|
||||||
|
const system = await api.updateSystem(projectId, systemId, data);
|
||||||
|
const list = systemsByProject.value[projectId];
|
||||||
|
if (list) {
|
||||||
|
const idx = list.findIndex((s) => s.id === systemId);
|
||||||
|
if (idx >= 0) list[idx] = system;
|
||||||
|
}
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveSystem(projectId: number, systemId: number) {
|
||||||
|
return updateSystem(projectId, systemId, { status: "archived" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unarchiveSystem(projectId: number, systemId: number) {
|
||||||
|
return updateSystem(projectId, systemId, { status: "active" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSystem(projectId: number, systemId: number) {
|
||||||
|
await api.deleteSystem(projectId, systemId);
|
||||||
|
const list = systemsByProject.value[projectId];
|
||||||
|
if (list) {
|
||||||
|
systemsByProject.value[projectId] = list.filter((s) => s.id !== systemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
systemsByProject,
|
||||||
|
loading,
|
||||||
|
fetchSystems,
|
||||||
|
createSystem,
|
||||||
|
updateSystem,
|
||||||
|
archiveSystem,
|
||||||
|
unarchiveSystem,
|
||||||
|
deleteSystem,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -3,6 +3,16 @@ import { defineStore } from "pinia";
|
|||||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
|
import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
|
||||||
|
import type { TaskKind } from "@/types/note";
|
||||||
|
|
||||||
|
// Issues + Systems write-only fields accepted by the task create/update API.
|
||||||
|
// `kind` selects work/plan/issue; system_ids / arose_from_id are not mirrored
|
||||||
|
// 1:1 on the Task model (the read side exposes `systems` + `arose_from_id`).
|
||||||
|
interface IssueFields {
|
||||||
|
kind?: TaskKind;
|
||||||
|
system_ids?: number[];
|
||||||
|
arose_from_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Single-task + mutation surface. The list/filter/sort/pagination surface that
|
// Single-task + mutation surface. The list/filter/sort/pagination surface that
|
||||||
// backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit
|
// backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit
|
||||||
@@ -34,7 +44,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
milestone_id?: number | null;
|
milestone_id?: number | null;
|
||||||
parent_id?: number | null;
|
parent_id?: number | null;
|
||||||
recurrence_rule?: Record<string, unknown> | null;
|
recurrence_rule?: Record<string, unknown> | null;
|
||||||
}): Promise<Task> {
|
} & IssueFields): Promise<Task> {
|
||||||
try {
|
try {
|
||||||
return await apiPost<Task>("/api/tasks", data);
|
return await apiPost<Task>("/api/tasks", data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -47,7 +57,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
id: number,
|
id: number,
|
||||||
data: Partial<
|
data: Partial<
|
||||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
||||||
>
|
> & IssueFields
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
try {
|
try {
|
||||||
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
|
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import type { System } from "@/api/systems";
|
||||||
|
|
||||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||||
export type NoteType = "note" | "person" | "place" | "list" | "process";
|
export type TaskKind = "work" | "plan" | "issue";
|
||||||
|
export type NoteType = "note" | "process";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -22,8 +25,9 @@ export interface Note {
|
|||||||
recurrence_next_spawn_at: string | null;
|
recurrence_next_spawn_at: string | null;
|
||||||
is_task: boolean;
|
is_task: boolean;
|
||||||
note_type: NoteType;
|
note_type: NoteType;
|
||||||
task_kind?: "work" | "plan";
|
task_kind?: TaskKind;
|
||||||
metadata: Record<string, string>;
|
systems?: System[];
|
||||||
|
arose_from_id?: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,18 @@ export interface TaskListResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// start_planning now creates a MILESTONE (the plan container), not a kind=plan
|
||||||
|
// task. The milestone's `body` holds the design; steps live as child tasks.
|
||||||
export interface StartPlanningResult {
|
export interface StartPlanningResult {
|
||||||
task: import("./note").Note;
|
milestone: {
|
||||||
|
id: number;
|
||||||
|
project_id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
body: string | null;
|
||||||
|
status: string;
|
||||||
|
order_index: number;
|
||||||
|
};
|
||||||
applicable_rules: {
|
applicable_rules: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -1,763 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
|
||||||
import FullCalendar from "@fullcalendar/vue3";
|
|
||||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
|
||||||
import interactionPlugin from "@fullcalendar/interaction";
|
|
||||||
import type { CalendarOptions, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
|
||||||
import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction";
|
|
||||||
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
|
|
||||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
|
||||||
import { useToastStore } from "@/stores/toast";
|
|
||||||
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
|
|
||||||
import { MapPin } from "lucide-vue-next";
|
|
||||||
|
|
||||||
const toast = useToastStore();
|
|
||||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
|
||||||
|
|
||||||
// Slide-over state
|
|
||||||
const slideOverEvent = ref<EventEntry | null>(null);
|
|
||||||
const slideOverOpen = ref(false);
|
|
||||||
const slideOverDate = ref<string>("");
|
|
||||||
|
|
||||||
function openCreate(date: string) {
|
|
||||||
slideOverEvent.value = null;
|
|
||||||
slideOverDate.value = date;
|
|
||||||
slideOverOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(event: EventEntry) {
|
|
||||||
slideOverEvent.value = event;
|
|
||||||
slideOverDate.value = "";
|
|
||||||
slideOverOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSlideOver() {
|
|
||||||
slideOverOpen.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event entry cache keyed by id
|
|
||||||
const eventCache = new Map<number, EventEntry>();
|
|
||||||
|
|
||||||
function toFcEvent(e: EventEntry) {
|
|
||||||
// For all-day events pass date-only strings so FullCalendar never shifts
|
|
||||||
// the date through timezone conversion (UTC midnight → previous day in UTC-X).
|
|
||||||
return {
|
|
||||||
id: String(e.id),
|
|
||||||
title: e.title,
|
|
||||||
start: e.all_day ? e.start_dt.slice(0, 10) : e.start_dt,
|
|
||||||
end: e.all_day ? (e.end_dt?.slice(0, 10) ?? undefined) : (e.end_dt ?? undefined),
|
|
||||||
allDay: e.all_day,
|
|
||||||
backgroundColor: e.color || undefined,
|
|
||||||
borderColor: e.color || undefined,
|
|
||||||
extendedProps: { entryId: e.id },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Upcoming events list ───────────────────────────────────────────────────
|
|
||||||
const upcomingEvents = ref<EventEntry[]>([]);
|
|
||||||
|
|
||||||
async function loadUpcoming() {
|
|
||||||
const now = new Date();
|
|
||||||
const end = new Date(now.getTime() + 28 * 86_400_000); // 4 weeks
|
|
||||||
try {
|
|
||||||
const entries = await listEvents(now.toISOString(), end.toISOString());
|
|
||||||
upcomingEvents.value = entries.sort(
|
|
||||||
(a, b) => new Date(a.start_dt).getTime() - new Date(b.start_dt).getTime()
|
|
||||||
);
|
|
||||||
} catch { /* silent */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadUpcoming);
|
|
||||||
|
|
||||||
// ── Month/year picker ──────────────────────────────────────────────────────
|
|
||||||
const currentViewYear = ref(new Date().getFullYear());
|
|
||||||
const currentViewMonth = ref(new Date().getMonth());
|
|
||||||
const pickerOpen = ref(false);
|
|
||||||
const pickerYear = ref(new Date().getFullYear());
|
|
||||||
const pickerStyle = ref<Record<string, string>>({});
|
|
||||||
const pickerEl = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
|
|
||||||
|
|
||||||
function handleDatesSet(arg: { view: { currentStart: Date } }) {
|
|
||||||
const d = arg.view.currentStart;
|
|
||||||
currentViewYear.value = d.getFullYear();
|
|
||||||
currentViewMonth.value = d.getMonth();
|
|
||||||
}
|
|
||||||
|
|
||||||
function jumpTo(year: number, month: number) {
|
|
||||||
calendarRef.value?.getApi().gotoDate(new Date(year, month, 1));
|
|
||||||
pickerOpen.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event popover ──────────────────────────────────────────────────────────
|
|
||||||
const popover = ref<EventEntry | null>(null);
|
|
||||||
const popoverStyle = ref<Record<string, string>>({});
|
|
||||||
const popoverEl = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
function showPopover(entry: EventEntry, clickEvent: MouseEvent) {
|
|
||||||
popover.value = entry;
|
|
||||||
nextTickPositionPopover(clickEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextTickPositionPopover(clickEvent: MouseEvent) {
|
|
||||||
// Position after DOM update
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const vw = window.innerWidth;
|
|
||||||
const vh = window.innerHeight;
|
|
||||||
const pw = 280;
|
|
||||||
const ph = 220; // approximate
|
|
||||||
let left = clickEvent.clientX + 8;
|
|
||||||
let top = clickEvent.clientY + 8;
|
|
||||||
if (left + pw > vw - 16) left = clickEvent.clientX - pw - 8;
|
|
||||||
if (top + ph > vh - 16) top = clickEvent.clientY - ph - 8;
|
|
||||||
popoverStyle.value = {
|
|
||||||
position: "fixed",
|
|
||||||
left: `${Math.max(8, left)}px`,
|
|
||||||
top: `${Math.max(8, top)}px`,
|
|
||||||
zIndex: "9999",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePopover() {
|
|
||||||
popover.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPopoverEdit() {
|
|
||||||
if (popover.value) {
|
|
||||||
openEdit(popover.value);
|
|
||||||
closePopover();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close popover / open picker on outside or title click
|
|
||||||
function onDocClick(e: MouseEvent) {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
// Title click → toggle month/year picker
|
|
||||||
const titleEl = target.closest(".fc-toolbar-title");
|
|
||||||
if (titleEl) {
|
|
||||||
if (!pickerOpen.value) {
|
|
||||||
pickerYear.value = currentViewYear.value;
|
|
||||||
const rect = titleEl.getBoundingClientRect();
|
|
||||||
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - 140, window.innerWidth - 296));
|
|
||||||
pickerStyle.value = {
|
|
||||||
position: "fixed",
|
|
||||||
top: `${rect.bottom + 6}px`,
|
|
||||||
left: `${left}px`,
|
|
||||||
zIndex: "9999",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
pickerOpen.value = !pickerOpen.value;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Close picker on outside click
|
|
||||||
if (pickerOpen.value && pickerEl.value && !pickerEl.value.contains(target)) {
|
|
||||||
pickerOpen.value = false;
|
|
||||||
}
|
|
||||||
// Close event popover on outside click
|
|
||||||
if (popover.value && popoverEl.value && !popoverEl.value.contains(target)) {
|
|
||||||
closePopover();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCalendarChanged() {
|
|
||||||
calendarRef.value?.getApi().refetchEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener("mousedown", onDocClick);
|
|
||||||
document.addEventListener("fable:calendar-changed", onCalendarChanged);
|
|
||||||
});
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener("mousedown", onDocClick);
|
|
||||||
document.removeEventListener("fable:calendar-changed", onCalendarChanged);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Calendar callbacks ─────────────────────────────────────────────────────
|
|
||||||
async function loadEvents(
|
|
||||||
fetchInfo: { startStr: string; endStr: string },
|
|
||||||
successCallback: (events: object[]) => void,
|
|
||||||
failureCallback: (error: Error) => void,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const entries = await listEvents(fetchInfo.startStr, fetchInfo.endStr);
|
|
||||||
eventCache.clear();
|
|
||||||
for (const e of entries) eventCache.set(e.id, e);
|
|
||||||
successCallback(entries.map(toFcEvent));
|
|
||||||
loadUpcoming();
|
|
||||||
} catch (err) {
|
|
||||||
failureCallback(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDateClick(arg: DateClickArg) {
|
|
||||||
closePopover();
|
|
||||||
openCreate(arg.dateStr);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEventClick(arg: EventClickArg) {
|
|
||||||
const id = arg.event.extendedProps.entryId as number;
|
|
||||||
const entry = eventCache.get(id);
|
|
||||||
if (entry) showPopover(entry, arg.jsEvent as MouseEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEventDrop(arg: EventDropArg) {
|
|
||||||
const id = arg.event.extendedProps.entryId as number;
|
|
||||||
const start_dt = arg.event.startStr;
|
|
||||||
const end_dt = arg.event.endStr || undefined;
|
|
||||||
try {
|
|
||||||
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
|
|
||||||
eventCache.set(id, updated);
|
|
||||||
loadUpcoming();
|
|
||||||
} catch {
|
|
||||||
arg.revert();
|
|
||||||
toast.show("Failed to move event", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEventResize(arg: EventResizeDoneArg) {
|
|
||||||
const id = arg.event.extendedProps.entryId as number;
|
|
||||||
const start_dt = arg.event.startStr;
|
|
||||||
const end_dt = arg.event.endStr || undefined;
|
|
||||||
try {
|
|
||||||
const updated = await updateEvent(id, { start_dt, end_dt });
|
|
||||||
eventCache.set(id, updated);
|
|
||||||
loadUpcoming();
|
|
||||||
} catch {
|
|
||||||
arg.revert();
|
|
||||||
toast.show("Failed to resize event", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCreated(entry: EventEntry) {
|
|
||||||
eventCache.set(entry.id, entry);
|
|
||||||
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
|
|
||||||
closeSlideOver();
|
|
||||||
loadUpcoming();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUpdated(entry: EventEntry) {
|
|
||||||
eventCache.set(entry.id, entry);
|
|
||||||
const api = calendarRef.value?.getApi();
|
|
||||||
if (api) {
|
|
||||||
const existing = api.getEventById(String(entry.id));
|
|
||||||
if (existing) {
|
|
||||||
existing.remove();
|
|
||||||
api.addEvent(toFcEvent(entry));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
closeSlideOver();
|
|
||||||
loadUpcoming();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDeleted(id: number) {
|
|
||||||
eventCache.delete(id);
|
|
||||||
const api = calendarRef.value?.getApi();
|
|
||||||
if (api) api.getEventById(String(id))?.remove();
|
|
||||||
closeSlideOver();
|
|
||||||
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendarOptions: CalendarOptions = {
|
|
||||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
|
||||||
initialView: "dayGridMonth",
|
|
||||||
timeZone: "local",
|
|
||||||
editable: true,
|
|
||||||
selectable: false,
|
|
||||||
headerToolbar: {
|
|
||||||
left: "prev,next today",
|
|
||||||
center: "title",
|
|
||||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
|
||||||
},
|
|
||||||
events: loadEvents,
|
|
||||||
datesSet: handleDatesSet,
|
|
||||||
dateClick: handleDateClick,
|
|
||||||
eventClick: handleEventClick,
|
|
||||||
eventDrop: handleEventDrop,
|
|
||||||
eventResize: handleEventResize,
|
|
||||||
height: "auto",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group upcoming events by day label
|
|
||||||
const upcomingGrouped = computed(() => {
|
|
||||||
const groups: { label: string; date: string; events: EventEntry[] }[] = [];
|
|
||||||
for (const e of upcomingEvents.value) {
|
|
||||||
const label = fmtDayLabel(e.start_dt);
|
|
||||||
const existing = groups.find((g) => g.label === label);
|
|
||||||
if (existing) {
|
|
||||||
existing.events.push(e);
|
|
||||||
} else {
|
|
||||||
groups.push({ label, date: e.start_dt, events: [e] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="calendar-view">
|
|
||||||
<div class="cal-header">
|
|
||||||
<h1 class="cal-title">Calendar</h1>
|
|
||||||
<button class="btn-new-event" @click="openCreate(new Date().toISOString().slice(0, 10))">
|
|
||||||
+ New Event
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fc-wrapper">
|
|
||||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Upcoming events strip ───────────────────────────────────────── -->
|
|
||||||
<div v-if="upcomingEvents.length" class="upcoming-section">
|
|
||||||
<h2 class="upcoming-title">Upcoming</h2>
|
|
||||||
<div class="upcoming-groups">
|
|
||||||
<div v-for="group in upcomingGrouped" :key="group.label" class="upcoming-group">
|
|
||||||
<div class="upcoming-day-label">{{ group.label }}</div>
|
|
||||||
<div class="upcoming-cards">
|
|
||||||
<div
|
|
||||||
v-for="ev in group.events"
|
|
||||||
:key="ev.id"
|
|
||||||
class="upcoming-card"
|
|
||||||
:style="ev.color ? { '--ev-color': ev.color } : {}"
|
|
||||||
@click="openEdit(ev)"
|
|
||||||
>
|
|
||||||
<div class="upcoming-card-accent"></div>
|
|
||||||
<div class="upcoming-card-body">
|
|
||||||
<div class="upcoming-card-title">{{ ev.title }}</div>
|
|
||||||
<div class="upcoming-card-time">
|
|
||||||
<template v-if="ev.all_day">All day</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ fmtTime(ev.start_dt) }}
|
|
||||||
<span v-if="ev.end_dt"> – {{ fmtTime(ev.end_dt) }}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div v-if="ev.location" class="upcoming-card-meta">
|
|
||||||
<MapPin :size="16" style="flex-shrink:0" />
|
|
||||||
{{ ev.location }}
|
|
||||||
</div>
|
|
||||||
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Event popover ───────────────────────────────────────────────── -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="popover"
|
|
||||||
ref="popoverEl"
|
|
||||||
class="event-popover"
|
|
||||||
:style="popoverStyle"
|
|
||||||
>
|
|
||||||
<div class="popover-accent" :style="popover.color ? { background: popover.color } : {}"></div>
|
|
||||||
<div class="popover-content">
|
|
||||||
<div class="popover-title">{{ popover.title }}</div>
|
|
||||||
<div class="popover-time">
|
|
||||||
<template v-if="popover.all_day">
|
|
||||||
{{ fmtDateTime(popover.start_dt, true) }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ fmtDateTime(popover.start_dt, false) }}
|
|
||||||
<span v-if="popover.end_dt"> – {{ fmtTime(popover.end_dt) }}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div v-if="popover.location" class="popover-meta">
|
|
||||||
<MapPin :size="16" style="flex-shrink:0;margin-top:1px" />
|
|
||||||
{{ popover.location }}
|
|
||||||
</div>
|
|
||||||
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
|
|
||||||
<div class="popover-actions">
|
|
||||||
<button class="popover-btn popover-btn--edit" @click="onPopoverEdit">Edit</button>
|
|
||||||
<button class="popover-btn popover-btn--close" @click="closePopover">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<!-- ── Month/year picker ──────────────────────────────────────────── -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="pickerOpen" ref="pickerEl" class="month-picker" :style="pickerStyle">
|
|
||||||
<div class="picker-year-row">
|
|
||||||
<button class="picker-year-btn" @click="pickerYear--" aria-label="Previous year">‹</button>
|
|
||||||
<span class="picker-year-label">{{ pickerYear }}</span>
|
|
||||||
<button class="picker-year-btn" @click="pickerYear++" aria-label="Next year">›</button>
|
|
||||||
</div>
|
|
||||||
<div class="picker-months">
|
|
||||||
<button
|
|
||||||
v-for="(name, i) in MONTH_NAMES"
|
|
||||||
:key="i"
|
|
||||||
class="picker-month"
|
|
||||||
:class="{ active: pickerYear === currentViewYear && i === currentViewMonth }"
|
|
||||||
@click="jumpTo(pickerYear, i)"
|
|
||||||
>{{ name }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<EventSlideOver
|
|
||||||
v-if="slideOverOpen"
|
|
||||||
:event="slideOverEvent"
|
|
||||||
:initial-date="slideOverDate"
|
|
||||||
@close="closeSlideOver"
|
|
||||||
@created="onCreated"
|
|
||||||
@updated="onUpdated"
|
|
||||||
@deleted="onDeleted"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.calendar-view {
|
|
||||||
max-width: var(--page-max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem 1.5rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cal-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* New event: Moss action-primary — workflow action, not a brand moment */
|
|
||||||
.btn-new-event {
|
|
||||||
background: var(--color-action-primary);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.55rem 1.1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-new-event:hover { background: var(--color-action-primary-hover); }
|
|
||||||
|
|
||||||
.fc-wrapper {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: var(--radius-lg, 18px);
|
|
||||||
padding: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FullCalendar dark theme overrides */
|
|
||||||
:deep(.fc) {
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
:deep(.fc-toolbar-title) {
|
|
||||||
/* FullCalendar renders this as <h2>, which would otherwise pick up the
|
|
||||||
theme.css h1/h2 → Fraunces rule. At 1.1rem it's below the doc's
|
|
||||||
"Fraunces only at ≥18px" threshold, so explicit Inter. */
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
:deep(.fc-toolbar-title:hover) {
|
|
||||||
background: rgba(255,255,255,0.07);
|
|
||||||
}
|
|
||||||
:deep(.fc-button) {
|
|
||||||
background: var(--color-input-bg, var(--color-bg));
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
padding: 0.3rem 0.7rem;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
:deep(.fc-button:hover),
|
|
||||||
:deep(.fc-button-active) {
|
|
||||||
background: var(--color-primary) !important;
|
|
||||||
border-color: var(--color-primary) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
:deep(.fc-button:focus) { box-shadow: none !important; }
|
|
||||||
:deep(.fc-daygrid-day-number),
|
|
||||||
:deep(.fc-col-header-cell-cushion) {
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
:deep(.fc-daygrid-day.fc-day-today) {
|
|
||||||
background: var(--color-primary-tint);
|
|
||||||
}
|
|
||||||
:deep(.fc-event) {
|
|
||||||
border-radius: 4px;
|
|
||||||
border: none;
|
|
||||||
padding: 1px 4px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
:deep(.fc-event-main) { color: #fff; }
|
|
||||||
:deep(.fc-event:not([style*="background"])) {
|
|
||||||
background: var(--color-primary);
|
|
||||||
}
|
|
||||||
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
|
|
||||||
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
|
|
||||||
:deep(.fc-scrollgrid td),
|
|
||||||
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
|
|
||||||
:deep(.fc-daygrid-day) { cursor: pointer; }
|
|
||||||
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
|
||||||
|
|
||||||
/* ── Month/year picker ──────────────────────────────────────────────────── */
|
|
||||||
.month-picker {
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
||||||
width: 280px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-year-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-year-label {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-year-btn {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.picker-year-btn:hover {
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
border-color: rgba(255,255,255,0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-months {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-month {
|
|
||||||
padding: 7px 4px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
text-align: center;
|
|
||||||
transition: background 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
.picker-month:hover {
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
}
|
|
||||||
.picker-month.active {
|
|
||||||
background: rgba(91, 74, 138,0.22);
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
|
||||||
.upcoming-section {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-title {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-groups {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-day-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-cards {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 0;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
.upcoming-card:hover {
|
|
||||||
border-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-card-accent {
|
|
||||||
width: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--ev-color, #5B4A8A);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-card-body {
|
|
||||||
padding: 0.6rem 0.85rem;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-card-title {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-card-time {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-card-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.3rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upcoming-card-desc {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-secondary, #aaa);
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Event popover ──────────────────────────────────────────────────────── */
|
|
||||||
.event-popover {
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
|
||||||
width: 280px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-accent {
|
|
||||||
height: 4px;
|
|
||||||
background: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-content {
|
|
||||||
padding: 0.85rem 1rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-title {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text, #e8e9f0);
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-time {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.3rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-desc {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-text-secondary, #aaa);
|
|
||||||
line-height: 1.45;
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 4;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
border-top: 1px solid var(--color-border, #2a2b30);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.35rem 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.popover-btn:hover { opacity: 0.85; }
|
|
||||||
.popover-btn--edit {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.popover-btn--close {
|
|
||||||
background: var(--color-input-bg, var(--color-bg));
|
|
||||||
color: var(--color-text-muted, #888);
|
|
||||||
border: 1px solid var(--color-border, #2a2b30);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -7,39 +7,33 @@ interface TaskRow { id: number; title: string; status: string; priority: string
|
|||||||
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
||||||
interface ActiveProject {
|
interface ActiveProject {
|
||||||
id: number; title: string; color: string | null; last_activity: string;
|
id: number; title: string; color: string | null; last_activity: string;
|
||||||
open_count: number; progress_pct: number;
|
open_count: number; done_count: number; progress_pct: number;
|
||||||
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
||||||
}
|
}
|
||||||
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
||||||
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
|
|
||||||
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
||||||
|
interface IssueRow { id: number; title: string; status: string; priority: string; project_id: number | null; project_title: string | null }
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
active_projects: ActiveProject[];
|
active_projects: ActiveProject[];
|
||||||
recently_completed: DoneItem[];
|
recently_completed: DoneItem[];
|
||||||
upcoming_events: UpcomingEvent[];
|
open_issues: IssueRow[];
|
||||||
week_stats: WeekStats;
|
week_stats: WeekStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = ref<DashboardData | null>(null);
|
const data = ref<DashboardData | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const showAllDone = ref(false);
|
||||||
|
const DONE_VISIBLE = 5;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
data.value = await apiGet<DashboardData>("/api/dashboard");
|
data.value = await apiGet<DashboardData>("/api/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
data.value = { active_projects: [], recently_completed: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function fmtEvent(e: UpcomingEvent): string {
|
|
||||||
if (!e.start_dt) return "";
|
|
||||||
const d = new Date(e.start_dt);
|
|
||||||
const day = d.toLocaleDateString(undefined, { weekday: "short" });
|
|
||||||
if (e.all_day) return day;
|
|
||||||
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -53,17 +47,25 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
|
|
||||||
<template v-else-if="data">
|
<template v-else-if="data">
|
||||||
<!-- Done recently -->
|
<!-- Done recently -->
|
||||||
<section v-if="data.recently_completed.length" class="done-strip">
|
<section v-if="data.recently_completed.length" class="done-recent">
|
||||||
<span class="dash-label">✓ Done recently</span>
|
<div class="dash-label">✓ Done recently</div>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="d in data.recently_completed"
|
v-for="d in (showAllDone ? data.recently_completed : data.recently_completed.slice(0, DONE_VISIBLE))"
|
||||||
:key="d.id"
|
:key="d.id"
|
||||||
:to="`/tasks/${d.id}`"
|
:to="`/tasks/${d.id}`"
|
||||||
class="done-chip"
|
class="done-row"
|
||||||
>
|
>
|
||||||
{{ d.title }}
|
<span class="done-mark">○</span>
|
||||||
|
<span class="done-title">{{ d.title }}</span>
|
||||||
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
|
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<button
|
||||||
|
v-if="data.recently_completed.length > DONE_VISIBLE"
|
||||||
|
class="done-more"
|
||||||
|
@click="showAllDone = !showAllDone"
|
||||||
|
>
|
||||||
|
{{ showAllDone ? "Show less" : `… ${data.recently_completed.length - DONE_VISIBLE} more` }}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="dash-cols">
|
<div class="dash-cols">
|
||||||
@@ -122,16 +124,21 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
|
|
||||||
<!-- Right rail -->
|
<!-- Right rail -->
|
||||||
<aside class="dash-rail">
|
<aside class="dash-rail">
|
||||||
<div class="dash-label">Upcoming · 7 days</div>
|
<template v-if="data.open_issues.length">
|
||||||
<div class="rail-card">
|
<div class="dash-label">⚠ Open issues</div>
|
||||||
<template v-if="data.upcoming_events.length">
|
<div class="rail-card issues-card">
|
||||||
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
|
<router-link
|
||||||
<span class="evt-when">{{ fmtEvent(e) }}</span>
|
v-for="i in data.open_issues"
|
||||||
<span class="evt-title">{{ e.title }}</span>
|
:key="i.id"
|
||||||
|
:to="`/tasks/${i.id}`"
|
||||||
|
class="issue-row"
|
||||||
|
>
|
||||||
|
<span class="issue-mark" :class="`imk-${i.status}`">{{ i.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||||
|
<span class="issue-title">{{ i.title }}</span>
|
||||||
|
<span class="issue-meta">{{ i.project_title || '—' }}</span>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="rail-empty">Nothing scheduled.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dash-label">This week</div>
|
<div class="dash-label">This week</div>
|
||||||
<div class="rail-card stats">
|
<div class="rail-card stats">
|
||||||
@@ -140,6 +147,22 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
|
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="data.active_projects.length">
|
||||||
|
<div class="dash-label">Projects</div>
|
||||||
|
<div class="rail-card proj-stats">
|
||||||
|
<router-link
|
||||||
|
v-for="p in data.active_projects"
|
||||||
|
:key="p.id"
|
||||||
|
:to="`/projects/${p.id}`"
|
||||||
|
class="pstat-row"
|
||||||
|
>
|
||||||
|
<span class="pstat-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
|
||||||
|
<span class="pstat-title">{{ p.title }}</span>
|
||||||
|
<span class="pstat-counts">{{ p.open_count }} open · {{ p.done_count }} done</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="quick-add">
|
<div class="quick-add">
|
||||||
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
|
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
|
||||||
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
|
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
|
||||||
@@ -159,10 +182,14 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
|
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
|
||||||
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
|
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
|
||||||
|
|
||||||
.done-strip { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; }
|
.done-recent { margin-bottom: 1.5rem; }
|
||||||
.done-chip { display: inline-flex; flex-direction: column; gap: 1px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 14px; padding: 4px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
.done-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
|
||||||
.done-chip:hover { border-color: var(--color-primary); }
|
.done-row:hover { background: var(--color-hover); }
|
||||||
.done-meta { font-size: 0.7rem; color: var(--color-muted); }
|
.done-mark { color: var(--color-primary); flex-shrink: 0; }
|
||||||
|
.done-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.done-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.done-more { background: none; border: none; cursor: pointer; margin-top: 0.25rem; padding: 0.2rem 0.55rem; font-size: 0.78rem; color: var(--color-primary); font-family: inherit; }
|
||||||
|
.done-more:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
||||||
.dash-main { flex: 1.7; min-width: 0; }
|
.dash-main { flex: 1.7; min-width: 0; }
|
||||||
@@ -193,16 +220,25 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
||||||
|
|
||||||
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
||||||
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
|
|
||||||
.evt-row:last-child { border-bottom: none; }
|
|
||||||
.evt-when { color: var(--color-muted); white-space: nowrap; }
|
|
||||||
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
|
|
||||||
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
||||||
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
||||||
|
.proj-stats { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
||||||
|
.pstat-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||||
|
.pstat-row:hover { background: var(--color-hover); }
|
||||||
|
.pstat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.pstat-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
||||||
|
.pstat-counts { font-size: 0.74rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||||
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||||
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
|
||||||
|
.issues-card { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
||||||
|
.issue-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||||
|
.issue-row:hover { background: var(--color-hover); }
|
||||||
|
.issue-mark { color: var(--color-muted); flex-shrink: 0; }
|
||||||
|
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
||||||
|
.issue-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.issue-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||||
|
|
||||||
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, apiPatch, listEvents } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import { fmtCompact } from "@/utils/dateFormat";
|
|
||||||
import GraphView from "@/views/GraphView.vue";
|
import GraphView from "@/views/GraphView.vue";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
User,
|
|
||||||
MapPin,
|
|
||||||
List,
|
|
||||||
Workflow,
|
Workflow,
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
@@ -24,28 +20,13 @@ const router = useRouter();
|
|||||||
|
|
||||||
interface KnowledgeItem {
|
interface KnowledgeItem {
|
||||||
id: number;
|
id: number;
|
||||||
note_type: "note" | "person" | "place" | "list" | "task" | "process";
|
note_type: "note" | "task" | "process";
|
||||||
title: string;
|
title: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
metadata: Record<string, string>;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
// type-specific
|
|
||||||
relationship?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
birthday?: string;
|
|
||||||
organization?: string;
|
|
||||||
address?: string;
|
|
||||||
hours?: string;
|
|
||||||
website?: string;
|
|
||||||
category?: string;
|
|
||||||
item_count?: number;
|
|
||||||
checked_count?: number;
|
|
||||||
list_items?: { text: string; checked: boolean }[];
|
|
||||||
body?: string;
|
|
||||||
// Task-specific
|
// Task-specific
|
||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
@@ -53,16 +34,9 @@ interface KnowledgeItem {
|
|||||||
task_kind?: "work" | "plan";
|
task_kind?: "work" | "plan";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpcomingEvent {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
start_dt: string;
|
|
||||||
all_day: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan" | "process">("");
|
const activeType = ref<"" | "note" | "task" | "plan" | "process">("");
|
||||||
const activeTag = ref("");
|
const activeTag = ref("");
|
||||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
@@ -70,8 +44,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|||||||
|
|
||||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; process: number; total: number }
|
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
|
||||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, process: 0, total: 0 });
|
const typeCounts = ref<KnowledgeCounts>({ note: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||||
|
|
||||||
async function fetchCounts() {
|
async function fetchCounts() {
|
||||||
try {
|
try {
|
||||||
@@ -212,26 +186,17 @@ watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
|||||||
|
|
||||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const upcomingEvents = ref<UpcomingEvent[]>([]);
|
|
||||||
const overdueCount = ref(0);
|
const overdueCount = ref(0);
|
||||||
|
|
||||||
async function fetchTodayBar() {
|
async function fetchTodayBar() {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const taskData = await apiGet<{ total: number }>(
|
||||||
const end = new Date(now.getTime() + 7 * 86_400_000);
|
|
||||||
const [events, taskData] = await Promise.all([
|
|
||||||
listEvents(now.toISOString(), end.toISOString()).catch(() => [] as UpcomingEvent[]),
|
|
||||||
apiGet<{ total: number }>(
|
|
||||||
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
||||||
).catch(() => ({ total: 0 })),
|
).catch(() => ({ total: 0 }));
|
||||||
]);
|
|
||||||
upcomingEvents.value = events.slice(0, 3);
|
|
||||||
overdueCount.value = taskData.total ?? 0;
|
overdueCount.value = taskData.total ?? 0;
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatEventDate = fmtCompact
|
|
||||||
|
|
||||||
// ─── Graph panel ──────────────────────────────────────────────────────────────
|
// ─── Graph panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
|
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
|
||||||
@@ -250,40 +215,6 @@ function toggleGraphExpand() {
|
|||||||
localStorage.setItem(_GRAPH_EXP_KEY, String(graphExpanded.value))
|
localStorage.setItem(_GRAPH_EXP_KEY, String(graphExpanded.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── List item toggle ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function toggleListItem(item: KnowledgeItem, index: number) {
|
|
||||||
if (!item.list_items || item.body === undefined) return;
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
const newChecked = !item.list_items[index].checked;
|
|
||||||
item.list_items[index].checked = newChecked;
|
|
||||||
item.checked_count = item.list_items.filter(i => i.checked).length;
|
|
||||||
|
|
||||||
// Rebuild the body by replacing the targeted checkbox line
|
|
||||||
let listIdx = 0;
|
|
||||||
const newBody = (item.body).split('\n').map(line => {
|
|
||||||
const stripped = line.trimStart();
|
|
||||||
if (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] ')) {
|
|
||||||
if (listIdx === index) {
|
|
||||||
const indent = line.length - stripped.length;
|
|
||||||
const newLine = (' '.repeat(indent)) + (newChecked ? '- [x] ' : '- [ ] ') + item.list_items![index].text;
|
|
||||||
listIdx++;
|
|
||||||
return newLine;
|
|
||||||
}
|
|
||||||
listIdx++;
|
|
||||||
}
|
|
||||||
return line;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
item.body = newBody;
|
|
||||||
try {
|
|
||||||
await apiPatch(`/api/notes/${item.id}`, { body: newBody });
|
|
||||||
} catch {
|
|
||||||
reset(); // revert on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function isOverdue(item: KnowledgeItem): boolean {
|
function isOverdue(item: KnowledgeItem): boolean {
|
||||||
@@ -352,22 +283,9 @@ onUnmounted(() => {
|
|||||||
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
|
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
|
||||||
|
|
||||||
<!-- Today bar -->
|
<!-- Today bar -->
|
||||||
<div class="today-bar">
|
<div v-if="overdueCount > 0" class="today-bar">
|
||||||
<div class="today-events">
|
|
||||||
<span v-if="upcomingEvents.length === 0" class="today-empty">No upcoming events</span>
|
|
||||||
<router-link
|
|
||||||
v-for="ev in upcomingEvents"
|
|
||||||
:key="ev.id"
|
|
||||||
:to="`/calendar`"
|
|
||||||
class="today-event-chip"
|
|
||||||
>
|
|
||||||
<span class="chip-dot"></span>
|
|
||||||
{{ ev.title }}
|
|
||||||
<span class="chip-date">{{ formatEventDate(ev.start_dt, ev.all_day) }}</span>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div class="today-actions">
|
<div class="today-actions">
|
||||||
<router-link v-if="overdueCount > 0" to="/tasks" class="overdue-badge">
|
<router-link to="/tasks" class="overdue-badge">
|
||||||
{{ overdueCount }} overdue
|
{{ overdueCount }} overdue
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,18 +310,6 @@ onUnmounted(() => {
|
|||||||
<CheckSquare :size="16" />
|
<CheckSquare :size="16" />
|
||||||
Task
|
Task
|
||||||
</button>
|
</button>
|
||||||
<button @click="createNew('person')">
|
|
||||||
<User :size="16" />
|
|
||||||
Person
|
|
||||||
</button>
|
|
||||||
<button @click="createNew('place')">
|
|
||||||
<MapPin :size="16" />
|
|
||||||
Place
|
|
||||||
</button>
|
|
||||||
<button @click="createNew('list')">
|
|
||||||
<List :size="16" />
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
<button @click="createNew('process')">
|
<button @click="createNew('process')">
|
||||||
<Workflow :size="16" />
|
<Workflow :size="16" />
|
||||||
Process
|
Process
|
||||||
@@ -422,11 +328,11 @@ onUnmounted(() => {
|
|||||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list'],['process','Processes','process']] as [string,string,string][])"
|
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['process','Processes','process']] as [string,string,string][])"
|
||||||
:key="val"
|
:key="val"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
:class="{ active: activeType === val }"
|
:class="{ active: activeType === val }"
|
||||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan' | 'process')"
|
@click="activeType = (val as '' | 'note' | 'task' | 'plan' | 'process')"
|
||||||
>
|
>
|
||||||
<span class="filter-btn-label">{{ label }}</span>
|
<span class="filter-btn-label">{{ label }}</span>
|
||||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||||
@@ -496,54 +402,15 @@ onUnmounted(() => {
|
|||||||
<!-- Type badge -->
|
<!-- Type badge -->
|
||||||
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
|
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
|
||||||
<span v-if="item.note_type === 'note'">Note</span>
|
<span v-if="item.note_type === 'note'">Note</span>
|
||||||
<span v-else-if="item.note_type === 'person'">Person</span>
|
|
||||||
<span v-else-if="item.note_type === 'place'">Place</span>
|
|
||||||
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||||
<span v-else-if="item.note_type === 'process'">Process</span>
|
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||||
<span v-else>List</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="k-card-body">
|
<div class="k-card-body">
|
||||||
<div class="k-card-title">{{ item.title }}</div>
|
<div class="k-card-title">{{ item.title }}</div>
|
||||||
|
|
||||||
<!-- Person specifics -->
|
|
||||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
|
||||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
|
||||||
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
|
|
||||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Place specifics -->
|
|
||||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
|
||||||
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
|
|
||||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
|
||||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- List specifics -->
|
|
||||||
<div v-else-if="item.note_type === 'list'" class="k-card-list" @click.stop>
|
|
||||||
<label
|
|
||||||
v-for="(li, idx) in (item.list_items ?? []).slice(0, 6)"
|
|
||||||
:key="idx"
|
|
||||||
class="list-item-row"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="li.checked"
|
|
||||||
@change="toggleListItem(item, idx)"
|
|
||||||
/>
|
|
||||||
<span :class="{ 'list-item-done': li.checked }">{{ li.text }}</span>
|
|
||||||
</label>
|
|
||||||
<div v-if="(item.list_items?.length ?? 0) > 6" class="list-item-more">
|
|
||||||
+{{ (item.list_items?.length ?? 0) - 6 }} more
|
|
||||||
</div>
|
|
||||||
<span class="list-progress" style="margin-top: 6px;">
|
|
||||||
<span class="list-progress-bar" :style="{ width: item.item_count ? `${Math.round(((item.checked_count ?? 0) / item.item_count) * 100)}%` : '0%' }"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Task specifics -->
|
<!-- Task specifics -->
|
||||||
<div v-else-if="item.note_type === 'task'" class="k-card-task">
|
<div v-if="item.note_type === 'task'" class="k-card-task">
|
||||||
<div class="task-badges">
|
<div class="task-badges">
|
||||||
<span class="status-badge" :class="`status--${item.status}`">
|
<span class="status-badge" :class="`status--${item.status}`">
|
||||||
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
||||||
@@ -631,35 +498,6 @@ onUnmounted(() => {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.today-events {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.today-empty {
|
|
||||||
color: var(--color-muted);
|
|
||||||
}
|
|
||||||
.today-event-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(91, 74, 138, 0.1);
|
|
||||||
border: 1px solid rgba(91, 74, 138, 0.2);
|
|
||||||
color: var(--color-text);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.today-event-chip:hover { background: rgba(91, 74, 138, 0.18); }
|
|
||||||
.chip-dot {
|
|
||||||
width: 6px; height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #5B4A8A;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
|
|
||||||
.today-actions { display: flex; align-items: center; gap: 10px; }
|
.today-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
.overdue-badge {
|
.overdue-badge {
|
||||||
padding: 2px 9px;
|
padding: 2px 9px;
|
||||||
@@ -929,14 +767,10 @@ onUnmounted(() => {
|
|||||||
/* Type-specific card DNA */
|
/* Type-specific card DNA */
|
||||||
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
||||||
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
||||||
.k-card--person { border-color: rgba(16, 185, 129, 0.18); }
|
|
||||||
.k-card--place { border-color: rgba(245, 158, 11, 0.18); }
|
|
||||||
.k-card--list { border-color: rgba(56, 189, 248, 0.18); }
|
|
||||||
|
|
||||||
/* Top gradient bars */
|
/* Top gradient bars */
|
||||||
.k-card--note::before,
|
.k-card--note::before,
|
||||||
.k-card--task::before,
|
.k-card--task::before {
|
||||||
.k-card--list::before {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -952,25 +786,6 @@ onUnmounted(() => {
|
|||||||
right: 0;
|
right: 0;
|
||||||
background: linear-gradient(90deg, #d4a017, #fbbf24);
|
background: linear-gradient(90deg, #d4a017, #fbbf24);
|
||||||
}
|
}
|
||||||
.k-card--list::before {
|
|
||||||
right: 0;
|
|
||||||
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Corner accents for entity types */
|
|
||||||
.k-card--person::after,
|
|
||||||
.k-card--place::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 0 14px 0 60px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
|
|
||||||
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
|
|
||||||
|
|
||||||
/* Type badge */
|
/* Type badge */
|
||||||
.type-badge {
|
.type-badge {
|
||||||
@@ -985,9 +800,6 @@ onUnmounted(() => {
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
|
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
|
||||||
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
|
|
||||||
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
|
||||||
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
|
||||||
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
||||||
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
||||||
|
|
||||||
@@ -1012,70 +824,6 @@ onUnmounted(() => {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.k-card-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.meta-chip {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
.meta-muted { color: var(--color-muted); }
|
|
||||||
|
|
||||||
/* List card items */
|
|
||||||
.k-card-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.list-item-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.list-item-row input[type="checkbox"] {
|
|
||||||
flex-shrink: 0;
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.list-item-done {
|
|
||||||
text-decoration: line-through;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.list-item-more {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* List progress bar */
|
|
||||||
.list-progress {
|
|
||||||
display: block;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.list-progress-bar {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
background: #38bdf8;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.k-card-footer {
|
.k-card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import type { NoteType } from "@/types/note";
|
import type { NoteType } from "@/types/note";
|
||||||
import { useNotesStore } from "@/stores/notes";
|
import { useNotesStore } from "@/stores/notes";
|
||||||
@@ -34,85 +34,10 @@ const tags = ref<string[]>([]);
|
|||||||
const projectId = ref<number | null>(null);
|
const projectId = ref<number | null>(null);
|
||||||
const milestoneId = ref<number | null>(null);
|
const milestoneId = ref<number | null>(null);
|
||||||
const noteType = ref<NoteType>("note");
|
const noteType = ref<NoteType>("note");
|
||||||
const entityMeta = reactive<Record<string, string>>({});
|
|
||||||
const dirty = ref(false);
|
const dirty = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const showPreview = ref(false);
|
const showPreview = ref(false);
|
||||||
const sidebarOpen = ref(true);
|
const sidebarOpen = ref(true);
|
||||||
const notesExpanded = ref(false);
|
|
||||||
|
|
||||||
// ── List builder ─────────────────────────────────────────────────────────────
|
|
||||||
interface ListItem {
|
|
||||||
text: string;
|
|
||||||
checked: boolean;
|
|
||||||
}
|
|
||||||
const listItems = ref<ListItem[]>([]);
|
|
||||||
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
|
|
||||||
|
|
||||||
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
|
|
||||||
const lines = bodyText.split('\n');
|
|
||||||
const items: ListItem[] = [];
|
|
||||||
const extraLines: string[] = [];
|
|
||||||
let pastList = false;
|
|
||||||
for (const line of lines) {
|
|
||||||
const stripped = line.trimStart();
|
|
||||||
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
|
|
||||||
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
|
|
||||||
} else if (!pastList && stripped === '' && items.length > 0) {
|
|
||||||
pastList = true;
|
|
||||||
} else {
|
|
||||||
pastList = true;
|
|
||||||
extraLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { items, extra: extraLines.join('\n').trim() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeListToBody(): string {
|
|
||||||
const listPart = listItems.value
|
|
||||||
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
|
||||||
.join('\n');
|
|
||||||
const extraPart = body.value.trim();
|
|
||||||
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addListItem(afterIndex?: number) {
|
|
||||||
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
|
|
||||||
listItems.value.splice(idx, 0, { text: '', checked: false });
|
|
||||||
markDirty();
|
|
||||||
nextTick(() => {
|
|
||||||
listItemRefs.value[idx]?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeListItem(index: number) {
|
|
||||||
if (listItems.value.length <= 1) return;
|
|
||||||
listItems.value.splice(index, 1);
|
|
||||||
markDirty();
|
|
||||||
nextTick(() => {
|
|
||||||
const focusIdx = Math.max(0, index - 1);
|
|
||||||
listItemRefs.value[focusIdx]?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onListItemKeydown(e: KeyboardEvent, index: number) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
addListItem(index);
|
|
||||||
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
|
|
||||||
e.preventDefault();
|
|
||||||
removeListItem(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onListItemInput(_index: number) {
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleListItemCheck(index: number) {
|
|
||||||
listItems.value[index].checked = !listItems.value[index].checked;
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||||
const titleRef = ref<HTMLInputElement | null>(null);
|
const titleRef = ref<HTMLInputElement | null>(null);
|
||||||
@@ -127,9 +52,6 @@ const isEditing = computed(() => noteId.value !== null);
|
|||||||
|
|
||||||
const titlePlaceholder = computed(() => {
|
const titlePlaceholder = computed(() => {
|
||||||
switch (noteType.value) {
|
switch (noteType.value) {
|
||||||
case 'person': return 'Name';
|
|
||||||
case 'place': return 'Place name';
|
|
||||||
case 'list': return 'List title';
|
|
||||||
case 'process': return 'Process name';
|
case 'process': return 'Process name';
|
||||||
default: return 'Title';
|
default: return 'Title';
|
||||||
}
|
}
|
||||||
@@ -276,7 +198,6 @@ let savedTags: string[] = [];
|
|||||||
let savedProjectId: number | null = null;
|
let savedProjectId: number | null = null;
|
||||||
let savedMilestoneId: number | null = null;
|
let savedMilestoneId: number | null = null;
|
||||||
let savedNoteType: NoteType = "note";
|
let savedNoteType: NoteType = "note";
|
||||||
let savedEntityMeta: Record<string, string> = {};
|
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
dirty.value =
|
dirty.value =
|
||||||
@@ -285,8 +206,7 @@ function markDirty() {
|
|||||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||||
projectId.value !== savedProjectId ||
|
projectId.value !== savedProjectId ||
|
||||||
milestoneId.value !== savedMilestoneId ||
|
milestoneId.value !== savedMilestoneId ||
|
||||||
noteType.value !== savedNoteType ||
|
noteType.value !== savedNoteType;
|
||||||
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBodyUpdate(newVal: string) {
|
function onBodyUpdate(newVal: string) {
|
||||||
@@ -304,31 +224,19 @@ onMounted(async () => {
|
|||||||
projectId.value = store.currentNote.project_id ?? null;
|
projectId.value = store.currentNote.project_id ?? null;
|
||||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||||||
Object.assign(entityMeta, store.currentNote.metadata || {});
|
|
||||||
notesExpanded.value = !!(store.currentNote.body || '').trim();
|
|
||||||
if (noteType.value === 'list') {
|
|
||||||
const parsed = parseListFromBody(body.value);
|
|
||||||
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
|
|
||||||
body.value = parsed.extra;
|
|
||||||
notesExpanded.value = !!parsed.extra;
|
|
||||||
}
|
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
savedTags = [...tags.value];
|
savedTags = [...tags.value];
|
||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedNoteType = noteType.value;
|
savedNoteType = noteType.value;
|
||||||
savedEntityMeta = { ...entityMeta };
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New note: read type from query param
|
// New note: read type from query param
|
||||||
const qt = route.query.type as string | undefined;
|
const qt = route.query.type as string | undefined;
|
||||||
if (qt && ["note", "person", "place", "list"].includes(qt)) {
|
if (qt && ["note", "process"].includes(qt)) {
|
||||||
noteType.value = qt as NoteType;
|
noteType.value = qt as NoteType;
|
||||||
}
|
}
|
||||||
if (noteType.value === 'list') {
|
|
||||||
listItems.value = [{ text: '', checked: false }];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore pending draft if any
|
// Restore pending draft if any
|
||||||
@@ -349,7 +257,7 @@ onMounted(async () => {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (saving.value) return;
|
if (saving.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
const finalBody = body.value;
|
||||||
try {
|
try {
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, {
|
||||||
@@ -359,7 +267,6 @@ async function save() {
|
|||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
note_type: noteType.value,
|
note_type: noteType.value,
|
||||||
metadata: { ...entityMeta },
|
|
||||||
});
|
});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
@@ -367,7 +274,6 @@ async function save() {
|
|||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedNoteType = noteType.value;
|
savedNoteType = noteType.value;
|
||||||
savedEntityMeta = { ...entityMeta };
|
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note saved");
|
toast.show("Note saved");
|
||||||
} else {
|
} else {
|
||||||
@@ -378,7 +284,6 @@ async function save() {
|
|||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
note_type: noteType.value,
|
note_type: noteType.value,
|
||||||
metadata: { ...entityMeta },
|
|
||||||
});
|
});
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note created");
|
toast.show("Note created");
|
||||||
@@ -414,12 +319,12 @@ async function confirmDelete() {
|
|||||||
async function doAutoSave() {
|
async function doAutoSave() {
|
||||||
if (!isEditing.value || saving.value) return;
|
if (!isEditing.value || saving.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
const finalBody = body.value;
|
||||||
try {
|
try {
|
||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, {
|
||||||
title: title.value, body: finalBody, tags: tags.value,
|
title: title.value, body: finalBody, tags: tags.value,
|
||||||
project_id: projectId.value, milestone_id: milestoneId.value,
|
project_id: projectId.value, milestone_id: milestoneId.value,
|
||||||
note_type: noteType.value, metadata: { ...entityMeta },
|
note_type: noteType.value,
|
||||||
});
|
});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
@@ -427,7 +332,6 @@ async function doAutoSave() {
|
|||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedNoteType = noteType.value;
|
savedNoteType = noteType.value;
|
||||||
savedEntityMeta = { ...entityMeta };
|
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Auto-saved");
|
toast.show("Auto-saved");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -473,138 +377,8 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
|
|
||||||
<!-- ── Main column ────────────────────────────────────────── -->
|
<!-- ── Main column ────────────────────────────────────────── -->
|
||||||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
||||||
<!-- ── Person form ──────────────────────────────────────── -->
|
|
||||||
<template v-if="noteType === 'person'">
|
|
||||||
<div class="entity-form">
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Relationship</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Birthday</label>
|
|
||||||
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Email</label>
|
|
||||||
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Phone</label>
|
|
||||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Organization</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Address</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, wikilinks, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── Place form ───────────────────────────────────────── -->
|
|
||||||
<template v-else-if="noteType === 'place'">
|
|
||||||
<div class="entity-form">
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Address</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Phone</label>
|
|
||||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Hours</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9am–5pm" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Website</label>
|
|
||||||
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
<div class="ef-field">
|
|
||||||
<label class="ef-label">Category</label>
|
|
||||||
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, wikilinks, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── List builder ─────────────────────────────────────── -->
|
|
||||||
<template v-else-if="noteType === 'list'">
|
|
||||||
<div class="list-builder">
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in listItems"
|
|
||||||
:key="idx"
|
|
||||||
class="lb-item"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="item.checked"
|
|
||||||
@change="toggleListItemCheck(idx)"
|
|
||||||
class="lb-check"
|
|
||||||
tabindex="-1"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
|
|
||||||
v-model="item.text"
|
|
||||||
class="lb-text"
|
|
||||||
placeholder="List item..."
|
|
||||||
@keydown="onListItemKeydown($event, idx)"
|
|
||||||
@input="onListItemInput(idx)"
|
|
||||||
/>
|
|
||||||
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">×</button>
|
|
||||||
</div>
|
|
||||||
<button class="lb-add" @click="addListItem()">+ Add item</button>
|
|
||||||
</div>
|
|
||||||
<div class="notes-section">
|
|
||||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
|
||||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
|
||||||
</button>
|
|
||||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
|
||||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
|
||||||
<TiptapEditor
|
|
||||||
ref="editorRef"
|
|
||||||
:modelValue="body"
|
|
||||||
placeholder="Additional notes, context..."
|
|
||||||
@update:modelValue="onBodyUpdate"
|
|
||||||
@escape="titleRef?.focus()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
||||||
<template v-else-if="noteType === 'process'">
|
<template v-if="noteType === 'process'">
|
||||||
<label class="ef-label">Prompt</label>
|
<label class="ef-label">Prompt</label>
|
||||||
<textarea
|
<textarea
|
||||||
class="prompt-editor"
|
class="prompt-editor"
|
||||||
@@ -718,9 +492,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
<label class="sb-label">Type</label>
|
<label class="sb-label">Type</label>
|
||||||
<select v-model="noteType" class="sb-select" @change="markDirty">
|
<select v-model="noteType" class="sb-select" @change="markDirty">
|
||||||
<option value="note">Note</option>
|
<option value="note">Note</option>
|
||||||
<option value="person">Person</option>
|
<option value="process">Process</option>
|
||||||
<option value="place">Place</option>
|
|
||||||
<option value="list">List</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1034,18 +806,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Entity form (Person / Place) ───────────────────────── */
|
/* ── Process editor ─────────────────────────────────────── */
|
||||||
.entity-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
.ef-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.ef-label {
|
.ef-label {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
@@ -1071,119 +832,6 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
.prompt-editor:focus {
|
.prompt-editor:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.ef-input {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.ef-input:focus {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: var(--focus-ring);
|
|
||||||
}
|
|
||||||
.ef-input::placeholder {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Notes section (collapsible TipTap) ─────────────────── */
|
|
||||||
.notes-section {
|
|
||||||
margin-top: 16px;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
padding-top: 12px;
|
|
||||||
}
|
|
||||||
.notes-toggle {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
.notes-toggle:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.notes-editor-wrap {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── List builder ───────────────────────────────────────── */
|
|
||||||
.list-builder {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
.lb-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.lb-check {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
accent-color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.lb-text {
|
|
||||||
flex: 1;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
.lb-text:focus {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
box-shadow: var(--focus-ring);
|
|
||||||
}
|
|
||||||
.lb-text::placeholder {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.lb-delete {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
.lb-item:hover .lb-delete,
|
|
||||||
.lb-text:focus ~ .lb-delete {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.lb-delete:hover {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.lb-add {
|
|
||||||
background: none;
|
|
||||||
border: 1px dashed var(--color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 7px 12px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 4px;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.lb-add:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Narrow screen: sidebar collapses */
|
/* Narrow screen: sidebar collapses */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface Project {
|
|||||||
goal: string | null;
|
goal: string | null;
|
||||||
status: "active" | "paused" | "completed" | "archived";
|
status: "active" | "paused" | "completed" | "archived";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
auto_summary: string | null;
|
|
||||||
permission?: string;
|
permission?: string;
|
||||||
is_shared?: boolean;
|
is_shared?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
|
|||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { useTasksStore } from "@/stores/tasks";
|
import { useTasksStore } from "@/stores/tasks";
|
||||||
import { relativeTime } from "@/composables/useRelativeTime";
|
import { relativeTime } from "@/composables/useRelativeTime";
|
||||||
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import ShareDialog from "@/components/ShareDialog.vue";
|
import ShareDialog from "@/components/ShareDialog.vue";
|
||||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||||
|
import SystemsSection from "@/components/SystemsSection.vue";
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -21,6 +23,8 @@ import {
|
|||||||
interface Milestone {
|
interface Milestone {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
body: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
order_index: number;
|
order_index: number;
|
||||||
pct: number;
|
pct: number;
|
||||||
@@ -37,7 +41,6 @@ interface Project {
|
|||||||
goal: string | null;
|
goal: string | null;
|
||||||
status: "active" | "paused" | "completed" | "archived";
|
status: "active" | "paused" | "completed" | "archived";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
auto_summary: string | null;
|
|
||||||
permission?: string;
|
permission?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -77,10 +80,15 @@ async function confirmStartPlanning() {
|
|||||||
if (!title || !project.value) return;
|
if (!title || !project.value) return;
|
||||||
planningBusy.value = true;
|
planningBusy.value = true;
|
||||||
try {
|
try {
|
||||||
|
// start_planning creates a MILESTONE (the plan container). Reload milestones,
|
||||||
|
// make sure the new one is expanded, and open its plan editor.
|
||||||
const result = await tasksStore.startPlanning(project.value.id, title);
|
const result = await tasksStore.startPlanning(project.value.id, title);
|
||||||
planTitle.value = "";
|
planTitle.value = "";
|
||||||
showStartPlanning.value = false;
|
showStartPlanning.value = false;
|
||||||
router.push(`/tasks/${result.task.id}`);
|
await loadMilestones();
|
||||||
|
collapsedMilestones.value.delete(result.milestone.id);
|
||||||
|
startEditPlan(result.milestone.id, result.milestone.body);
|
||||||
|
toast.show("Plan started");
|
||||||
} finally {
|
} finally {
|
||||||
planningBusy.value = false;
|
planningBusy.value = false;
|
||||||
}
|
}
|
||||||
@@ -88,7 +96,7 @@ async function confirmStartPlanning() {
|
|||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
const activeTab = ref<"tasks" | "notes" | "rules">("tasks");
|
const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks");
|
||||||
|
|
||||||
const tasks = ref<NoteItem[]>([]);
|
const tasks = ref<NoteItem[]>([]);
|
||||||
const notes = ref<NoteItem[]>([]);
|
const notes = ref<NoteItem[]>([]);
|
||||||
@@ -225,6 +233,39 @@ async function commitRenameMilestone(ms: Milestone) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plan body editing — a milestone IS the plan; its `body` holds the design.
|
||||||
|
const editingPlanId = ref<number | null>(null);
|
||||||
|
const editPlanBody = ref("");
|
||||||
|
const savingPlan = ref(false);
|
||||||
|
|
||||||
|
function startEditPlan(id: number, body: string | null) {
|
||||||
|
editingPlanId.value = id;
|
||||||
|
editPlanBody.value = body ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditPlan() {
|
||||||
|
editingPlanId.value = null;
|
||||||
|
editPlanBody.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitEditPlan(ms: Milestone) {
|
||||||
|
if (savingPlan.value) return;
|
||||||
|
savingPlan.value = true;
|
||||||
|
try {
|
||||||
|
await apiPatch(`/api/projects/${projectId.value}/milestones/${ms.id}`, {
|
||||||
|
body: editPlanBody.value,
|
||||||
|
});
|
||||||
|
await loadMilestones();
|
||||||
|
editingPlanId.value = null;
|
||||||
|
editPlanBody.value = "";
|
||||||
|
toast.show("Plan saved");
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to save plan", "error");
|
||||||
|
} finally {
|
||||||
|
savingPlan.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmDeleteMilestone() {
|
async function confirmDeleteMilestone() {
|
||||||
const ms = deletingMilestone.value;
|
const ms = deletingMilestone.value;
|
||||||
if (!ms) return;
|
if (!ms) return;
|
||||||
@@ -480,6 +521,9 @@ async function confirmDelete() {
|
|||||||
Notes
|
Notes
|
||||||
<span v-if="project.summary" class="tab-count">{{ project.summary.note_count }}</span>
|
<span v-if="project.summary" class="tab-count">{{ project.summary.note_count }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button :class="['tab-btn', { active: activeTab === 'systems' }]" @click="activeTab = 'systems'">
|
||||||
|
Systems
|
||||||
|
</button>
|
||||||
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
|
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
|
||||||
Rules
|
Rules
|
||||||
</button>
|
</button>
|
||||||
@@ -542,6 +586,9 @@ async function confirmDelete() {
|
|||||||
</div>
|
</div>
|
||||||
<span class="ms-pct">{{ group.milestone.pct }}%</span>
|
<span class="ms-pct">{{ group.milestone.pct }}%</span>
|
||||||
<div class="ms-actions" @click.stop>
|
<div class="ms-actions" @click.stop>
|
||||||
|
<button class="ms-action-btn" :title="group.milestone.body ? 'Edit plan' : 'Add plan'" @click="startEditPlan(group.milestone.id, group.milestone.body)">
|
||||||
|
<FileText :size="16" />
|
||||||
|
</button>
|
||||||
<button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)">
|
<button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)">
|
||||||
<Pencil :size="16" />
|
<Pencil :size="16" />
|
||||||
</button>
|
</button>
|
||||||
@@ -552,6 +599,31 @@ async function confirmDelete() {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Plan body: the milestone IS the plan; design lives here, steps are the tasks below. -->
|
||||||
|
<div
|
||||||
|
v-if="group.milestone && !collapsedMilestones.has(group.milestone.id) && (editingPlanId === group.milestone.id || group.milestone.body)"
|
||||||
|
class="ms-plan"
|
||||||
|
>
|
||||||
|
<template v-if="editingPlanId === group.milestone.id">
|
||||||
|
<textarea
|
||||||
|
v-model="editPlanBody"
|
||||||
|
class="ms-plan-editor"
|
||||||
|
rows="10"
|
||||||
|
placeholder="The plan: Goal / Approach / Verification. Track each step as a task below."
|
||||||
|
></textarea>
|
||||||
|
<div class="ms-plan-actions">
|
||||||
|
<button class="btn-secondary" @click="cancelEditPlan">Cancel</button>
|
||||||
|
<button class="btn-primary" :disabled="savingPlan" @click="commitEditPlan(group.milestone)">Save plan</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="ms-plan-rendered markdown-body"
|
||||||
|
@click="startEditPlan(group.milestone.id, group.milestone.body)"
|
||||||
|
v-html="renderMarkdown(group.milestone.body || '')"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
|
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
|
||||||
<!-- Todo column -->
|
<!-- Todo column -->
|
||||||
<div class="kanban-col col-todo">
|
<div class="kanban-col col-todo">
|
||||||
@@ -664,6 +736,9 @@ async function confirmDelete() {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Systems tab -->
|
||||||
|
<SystemsSection v-if="activeTab === 'systems'" :project-id="projectId" />
|
||||||
|
|
||||||
<!-- Rules tab -->
|
<!-- Rules tab -->
|
||||||
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
|
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1076,6 +1151,40 @@ async function confirmDelete() {
|
|||||||
.milestone-header.clickable { cursor: pointer; }
|
.milestone-header.clickable { cursor: pointer; }
|
||||||
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
||||||
|
|
||||||
|
/* Plan body: the milestone's design/intent, shown above its task columns. */
|
||||||
|
.ms-plan {
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 3%, var(--color-bg-card));
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.ms-plan-rendered { font-size: 0.85rem; color: var(--color-text); cursor: text; }
|
||||||
|
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); }
|
||||||
|
.ms-plan-editor {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.ms-plan-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.5rem; }
|
||||||
|
.ms-plan-actions .btn-primary,
|
||||||
|
.ms-plan-actions .btn-secondary {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.ms-plan-actions .btn-primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
|
||||||
|
.ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
.ms-plan-actions .btn-secondary { background: var(--color-bg-card); color: var(--color-text); }
|
||||||
|
|
||||||
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
|
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
|
||||||
.ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.ms-count {
|
.ms-count {
|
||||||
|
|||||||
+478
-174
@@ -17,6 +17,13 @@ const timezoneSaved = ref(false);
|
|||||||
const trashRetentionDays = ref("90");
|
const trashRetentionDays = ref("90");
|
||||||
const savingRetention = ref(false);
|
const savingRetention = ref(false);
|
||||||
const retentionSaved = ref(false);
|
const retentionSaved = ref(false);
|
||||||
|
// Knowledge auto-inject (per-user). Defaults mirror the backend
|
||||||
|
// (services/plugin_context: enabled, threshold 0.55, top-k 3).
|
||||||
|
const kbInjectEnabled = ref(true);
|
||||||
|
const kbInjectThreshold = ref("0.55");
|
||||||
|
const kbInjectTopK = ref("3");
|
||||||
|
const savingKbInject = ref(false);
|
||||||
|
const kbInjectSaved = ref(false);
|
||||||
|
|
||||||
// think_enabled setting removed 2026-05-23. The chat+curator architecture
|
// think_enabled setting removed 2026-05-23. The chat+curator architecture
|
||||||
// has tools=[] on the chat model; think on a no-tools conversational pass
|
// has tools=[] on the chat model; think on a no-tools conversational pass
|
||||||
@@ -56,6 +63,28 @@ async function saveRetention() {
|
|||||||
savingRetention.value = false;
|
savingRetention.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveKbInject() {
|
||||||
|
const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0));
|
||||||
|
const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1)));
|
||||||
|
kbInjectThreshold.value = String(t);
|
||||||
|
kbInjectTopK.value = String(k);
|
||||||
|
savingKbInject.value = true;
|
||||||
|
kbInjectSaved.value = false;
|
||||||
|
try {
|
||||||
|
await apiPut('/api/settings', {
|
||||||
|
kb_autoinject_enabled: kbInjectEnabled.value ? 'true' : 'false',
|
||||||
|
kb_autoinject_threshold: String(t),
|
||||||
|
kb_autoinject_top_k: String(k),
|
||||||
|
});
|
||||||
|
kbInjectSaved.value = true;
|
||||||
|
setTimeout(() => (kbInjectSaved.value = false), 2000);
|
||||||
|
} catch {
|
||||||
|
toastStore.show('Failed to save auto-inject settings', 'error');
|
||||||
|
} finally {
|
||||||
|
savingKbInject.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const newEmail = ref("");
|
const newEmail = ref("");
|
||||||
const emailPassword = ref("");
|
const emailPassword = ref("");
|
||||||
const changingEmail = ref(false);
|
const changingEmail = ref(false);
|
||||||
@@ -137,6 +166,53 @@ const claudeCodeCommand = computed(() => {
|
|||||||
--header "Authorization: Bearer ${effectiveApiKey.value}"`;
|
--header "Authorization: Bearer ${effectiveApiKey.value}"`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Plugin install — the recommended path. The Scribe plugin bundles the MCP
|
||||||
|
// connection, a session-start hook that surfaces your rules, and the Scribe
|
||||||
|
// process-skills. The marketplace is the Scribe app's own git repo; persisted
|
||||||
|
// per-browser like the MCP fields above.
|
||||||
|
const _MKT_KEY = 'plugin_marketplace_url';
|
||||||
|
const pluginMarketplaceUrl = ref<string>(localStorage.getItem(_MKT_KEY) || '');
|
||||||
|
watch(pluginMarketplaceUrl, (v) => localStorage.setItem(_MKT_KEY, (v || '').trim()));
|
||||||
|
// Instance default, set by an admin (Admin tab) and loaded on mount. Used when
|
||||||
|
// the per-browser field is blank so the install command is copyable out of the box.
|
||||||
|
const serverMarketplaceUrl = ref('');
|
||||||
|
const adminMarketplaceUrl = ref('');
|
||||||
|
const savingMarketplaceUrl = ref(false);
|
||||||
|
const marketplaceUrlSaved = ref(false);
|
||||||
|
|
||||||
|
// DB maintenance (admin) — daily targeted VACUUM (ANALYZE).
|
||||||
|
interface DbMaintTableResult { table: string; ok: boolean; elapsed_ms: number; error: string | null }
|
||||||
|
interface DbMaintRun { started_at: string; elapsed_ms: number; tables: DbMaintTableResult[] }
|
||||||
|
const dbMaintEnabled = ref(true);
|
||||||
|
const dbMaintHour = ref(4);
|
||||||
|
const dbMaintLastRun = ref<DbMaintRun | null>(null);
|
||||||
|
const savingDbMaint = ref(false);
|
||||||
|
const dbMaintSaved = ref(false);
|
||||||
|
const runningDbMaint = ref(false);
|
||||||
|
interface DbTableHealth {
|
||||||
|
table: string; live: number; dead: number; dead_pct: number;
|
||||||
|
total_bytes: number; mod_since_analyze: number;
|
||||||
|
last_vacuum: string | null; last_analyze: string | null;
|
||||||
|
}
|
||||||
|
interface DbHealth { db_bytes: number; tables: DbTableHealth[] }
|
||||||
|
const dbHealth = ref<DbHealth | null>(null);
|
||||||
|
const loadingHealth = ref(false);
|
||||||
|
const DEAD_PCT_WARN = 20; // dead-tuple ratio above this = autovacuum falling behind
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let v = n / 1024, i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||||
|
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
const pluginInstallCommands = computed(() => {
|
||||||
|
const mkt = (pluginMarketplaceUrl.value || '').trim()
|
||||||
|
|| serverMarketplaceUrl.value
|
||||||
|
|| '<your-scribe-repo>.git';
|
||||||
|
return `/plugin marketplace add ${mkt}\n/plugin install scribe@scribe-plugin`;
|
||||||
|
});
|
||||||
|
|
||||||
const mcpConfigSnippet = computed(() => JSON.stringify({
|
const mcpConfigSnippet = computed(() => JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
[effectiveMcpName.value]: {
|
[effectiveMcpName.value]: {
|
||||||
@@ -290,18 +366,6 @@ const notifySecurityAlerts = ref(true);
|
|||||||
const savingNotifications = ref(false);
|
const savingNotifications = ref(false);
|
||||||
const notificationsSaved = ref(false);
|
const notificationsSaved = ref(false);
|
||||||
|
|
||||||
// CalDAV settings (per-user)
|
|
||||||
const caldav = ref({
|
|
||||||
caldav_url: "",
|
|
||||||
caldav_username: "",
|
|
||||||
caldav_password: "",
|
|
||||||
caldav_calendar_name: "",
|
|
||||||
});
|
|
||||||
const savingCaldav = ref(false);
|
|
||||||
const caldavSaved = ref(false);
|
|
||||||
const testingCaldav = ref(false);
|
|
||||||
const caldavTestResult = ref<{ success: boolean; message?: string; error?: string; calendars?: string[] } | null>(null);
|
|
||||||
|
|
||||||
// SMTP settings (admin only)
|
// SMTP settings (admin only)
|
||||||
const smtp = ref({
|
const smtp = ref({
|
||||||
smtp_host: "",
|
smtp_host: "",
|
||||||
@@ -388,6 +452,13 @@ onMounted(async () => {
|
|||||||
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
||||||
userTimezone.value = allSettings.user_timezone ?? "";
|
userTimezone.value = allSettings.user_timezone ?? "";
|
||||||
trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
|
trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
|
||||||
|
kbInjectEnabled.value = allSettings.kb_autoinject_enabled !== "false";
|
||||||
|
if (allSettings.kb_autoinject_threshold !== undefined) {
|
||||||
|
kbInjectThreshold.value = allSettings.kb_autoinject_threshold;
|
||||||
|
}
|
||||||
|
if (allSettings.kb_autoinject_top_k !== undefined) {
|
||||||
|
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
|
||||||
|
}
|
||||||
if (allSettings.notify_task_reminders !== undefined) {
|
if (allSettings.notify_task_reminders !== undefined) {
|
||||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||||
}
|
}
|
||||||
@@ -400,14 +471,6 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
|
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
|
||||||
|
|
||||||
// Load CalDAV settings
|
|
||||||
try {
|
|
||||||
const caldavConfig = await apiGet<Record<string, string>>("/api/settings/caldav");
|
|
||||||
caldav.value = { ...caldav.value, ...caldavConfig };
|
|
||||||
} catch {
|
|
||||||
// CalDAV not configured yet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check SearXNG status
|
// Check SearXNG status
|
||||||
try {
|
try {
|
||||||
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
|
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
|
||||||
@@ -417,6 +480,30 @@ onMounted(async () => {
|
|||||||
searxngConfigured.value = false;
|
searxngConfigured.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugin marketplace URL (instance default; readable by all users so the
|
||||||
|
// install command in MCP Access is copyable).
|
||||||
|
try {
|
||||||
|
const mk = await apiGet<{ marketplace_url: string }>("/api/plugin/marketplace-url");
|
||||||
|
serverMarketplaceUrl.value = mk.marketplace_url || "";
|
||||||
|
adminMarketplaceUrl.value = mk.marketplace_url || "";
|
||||||
|
if (!pluginMarketplaceUrl.value) pluginMarketplaceUrl.value = mk.marketplace_url || "";
|
||||||
|
} catch {
|
||||||
|
// not configured yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB maintenance config (admin only — endpoint is admin-gated).
|
||||||
|
if (authStore.isAdmin) {
|
||||||
|
try {
|
||||||
|
const dm = await apiGet<{ enabled: boolean; hour: number; last_run: DbMaintRun | null }>("/api/admin/db-maintenance");
|
||||||
|
dbMaintEnabled.value = dm.enabled;
|
||||||
|
dbMaintHour.value = dm.hour;
|
||||||
|
dbMaintLastRun.value = dm.last_run;
|
||||||
|
} catch {
|
||||||
|
// leave defaults
|
||||||
|
}
|
||||||
|
await loadDbHealth();
|
||||||
|
}
|
||||||
|
|
||||||
// Load admin settings
|
// Load admin settings
|
||||||
if (authStore.isAdmin) {
|
if (authStore.isAdmin) {
|
||||||
try {
|
try {
|
||||||
@@ -510,7 +597,7 @@ async function exportData(scope: "user" | "full") {
|
|||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
|
a.download = `scribe-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(a.href);
|
URL.revokeObjectURL(a.href);
|
||||||
toastStore.show("Backup downloaded");
|
toastStore.show("Backup downloaded");
|
||||||
@@ -533,7 +620,7 @@ async function exportNotes(format: "markdown" | "json") {
|
|||||||
const stamp = new Date().toISOString().slice(0, 10);
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = `fabledassistant-${stamp}.${ext}`;
|
a.download = `scribe-${stamp}.${ext}`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(a.href);
|
URL.revokeObjectURL(a.href);
|
||||||
toastStore.show("Export downloaded");
|
toastStore.show("Export downloaded");
|
||||||
@@ -565,38 +652,6 @@ async function saveNotifications() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCaldav() {
|
|
||||||
savingCaldav.value = true;
|
|
||||||
caldavSaved.value = false;
|
|
||||||
try {
|
|
||||||
await apiPut("/api/settings/caldav", caldav.value);
|
|
||||||
caldavSaved.value = true;
|
|
||||||
setTimeout(() => (caldavSaved.value = false), 2000);
|
|
||||||
} catch {
|
|
||||||
toastStore.show("Failed to save CalDAV settings", "error");
|
|
||||||
} finally {
|
|
||||||
savingCaldav.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testCaldav() {
|
|
||||||
testingCaldav.value = true;
|
|
||||||
caldavTestResult.value = null;
|
|
||||||
try {
|
|
||||||
const result = await apiPost<{ success: boolean; message?: string; error?: string; calendars?: string[] }>("/api/settings/caldav/test", {});
|
|
||||||
caldavTestResult.value = result;
|
|
||||||
} catch (e: unknown) {
|
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
|
||||||
caldavTestResult.value = { success: false, error: body?.error || "Connection test failed" };
|
|
||||||
} else {
|
|
||||||
caldavTestResult.value = { success: false, error: "Connection test failed" };
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
testingCaldav.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSmtp() {
|
async function saveSmtp() {
|
||||||
savingSmtp.value = true;
|
savingSmtp.value = true;
|
||||||
smtpSaved.value = false;
|
smtpSaved.value = false;
|
||||||
@@ -646,6 +701,74 @@ async function saveBaseUrl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveMarketplaceUrl() {
|
||||||
|
savingMarketplaceUrl.value = true;
|
||||||
|
marketplaceUrlSaved.value = false;
|
||||||
|
try {
|
||||||
|
const url = adminMarketplaceUrl.value.trim();
|
||||||
|
await apiPut("/api/plugin/marketplace-url", { marketplace_url: url });
|
||||||
|
serverMarketplaceUrl.value = url;
|
||||||
|
// Reflect the new instance default in the copyable field unless the user
|
||||||
|
// set their own per-browser override.
|
||||||
|
if (!localStorage.getItem(_MKT_KEY)) pluginMarketplaceUrl.value = url;
|
||||||
|
marketplaceUrlSaved.value = true;
|
||||||
|
setTimeout(() => (marketplaceUrlSaved.value = false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
const body = (e as { body?: { error?: string } }).body;
|
||||||
|
toastStore.show(body?.error || "Failed to save marketplace URL", "error");
|
||||||
|
} finally {
|
||||||
|
savingMarketplaceUrl.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDbMaintenance() {
|
||||||
|
savingDbMaint.value = true;
|
||||||
|
dbMaintSaved.value = false;
|
||||||
|
try {
|
||||||
|
await apiPut("/api/admin/db-maintenance", {
|
||||||
|
enabled: dbMaintEnabled.value,
|
||||||
|
hour: dbMaintHour.value,
|
||||||
|
});
|
||||||
|
dbMaintSaved.value = true;
|
||||||
|
setTimeout(() => (dbMaintSaved.value = false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
const body = (e as { body?: { error?: string } }).body;
|
||||||
|
toastStore.show(body?.error || "Failed to save maintenance settings", "error");
|
||||||
|
} finally {
|
||||||
|
savingDbMaint.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDbHealth() {
|
||||||
|
loadingHealth.value = true;
|
||||||
|
try {
|
||||||
|
dbHealth.value = await apiGet<DbHealth>("/api/admin/db-maintenance/health");
|
||||||
|
} catch {
|
||||||
|
// leave previous value
|
||||||
|
} finally {
|
||||||
|
loadingHealth.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDbMaintenanceNow() {
|
||||||
|
runningDbMaint.value = true;
|
||||||
|
try {
|
||||||
|
const summary = await apiPost<DbMaintRun>("/api/admin/db-maintenance/run", {});
|
||||||
|
dbMaintLastRun.value = summary;
|
||||||
|
const failed = summary.tables.filter((t) => !t.ok).length;
|
||||||
|
toastStore.show(
|
||||||
|
failed ? `Maintenance ran with ${failed} error(s)` : "Maintenance complete",
|
||||||
|
failed ? "error" : "success",
|
||||||
|
);
|
||||||
|
await loadDbHealth(); // reflect the dead-tuple drop
|
||||||
|
} catch (e) {
|
||||||
|
const body = (e as { body?: { error?: string } }).body;
|
||||||
|
toastStore.show(body?.error || "Maintenance run failed", "error");
|
||||||
|
} finally {
|
||||||
|
runningDbMaint.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRestoreFile(event: Event) {
|
async function handleRestoreFile(event: Event) {
|
||||||
const file = (event.target as HTMLInputElement).files?.[0];
|
const file = (event.target as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -1026,6 +1149,58 @@ function formatUserDate(iso: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Knowledge auto-inject</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
When enabled, the Scribe plugin quietly surfaces the titles of your most
|
||||||
|
relevant notes on each prompt — never their full text — so Claude can pull
|
||||||
|
one in with <code>get_note(id)</code> only when it helps. Titles only, each
|
||||||
|
note at most once per session, and nothing is shown unless it clears the
|
||||||
|
confidence bar below.
|
||||||
|
</p>
|
||||||
|
<div class="checkbox-field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="kbInjectEnabled" />
|
||||||
|
Surface relevant note titles each prompt
|
||||||
|
</label>
|
||||||
|
<p class="field-hint">Off = notes reach context only when Claude searches for them.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="kb-inject-threshold">Confidence threshold (0–1)</label>
|
||||||
|
<input
|
||||||
|
id="kb-inject-threshold"
|
||||||
|
v-model="kbInjectThreshold"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
class="input"
|
||||||
|
style="max-width: 8rem"
|
||||||
|
/>
|
||||||
|
<p class="field-hint">Minimum similarity to surface a note. Higher = stricter (fewer, more certain). Deliberately above the 0.45 used for searches you trigger yourself.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="kb-inject-topk">Max notes per prompt</label>
|
||||||
|
<input
|
||||||
|
id="kb-inject-topk"
|
||||||
|
v-model="kbInjectTopK"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
step="1"
|
||||||
|
class="input"
|
||||||
|
style="max-width: 8rem"
|
||||||
|
/>
|
||||||
|
<p class="field-hint">Ceiling on titles surfaced at once (1–10).</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-save" @click="saveKbInject" :disabled="savingKbInject">
|
||||||
|
{{ savingKbInject ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="kbInjectSaved" class="saved-msg">Saved!</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Account ── -->
|
<!-- ── Account ── -->
|
||||||
@@ -1280,50 +1455,6 @@ function formatUserDate(iso: string): string {
|
|||||||
<!-- ── Integrations ── -->
|
<!-- ── Integrations ── -->
|
||||||
<div v-show="activeTab === 'integrations'" class="settings-grid">
|
<div v-show="activeTab === 'integrations'" class="settings-grid">
|
||||||
|
|
||||||
<section class="settings-section full-width">
|
|
||||||
<h2>Calendar (CalDAV)</h2>
|
|
||||||
<p class="section-desc">
|
|
||||||
Connect to a CalDAV server (Nextcloud, iCloud, Radicale, Baikal) to create and view calendar events from chat.
|
|
||||||
</p>
|
|
||||||
<div class="caldav-grid">
|
|
||||||
<div class="field">
|
|
||||||
<label for="caldav-url">CalDAV URL</label>
|
|
||||||
<input id="caldav-url" v-model="caldav.caldav_url" type="url" placeholder="https://cloud.example.com/remote.php/dav" class="input" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="caldav-username">Username</label>
|
|
||||||
<input id="caldav-username" v-model="caldav.caldav_username" type="text" class="input" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="caldav-password">Password</label>
|
|
||||||
<input id="caldav-password" v-model="caldav.caldav_password" type="password" class="input" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="caldav-calendar-name">Calendar Name</label>
|
|
||||||
<input id="caldav-calendar-name" v-model="caldav.caldav_calendar_name" type="text" placeholder="Leave empty to use first available" class="input" />
|
|
||||||
<p class="field-hint">Optional. Exact calendar name to use.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions" style="margin-bottom: 1rem;">
|
|
||||||
<button class="btn-save" @click="saveCaldav" :disabled="savingCaldav">
|
|
||||||
{{ savingCaldav ? "Saving..." : "Save" }}
|
|
||||||
</button>
|
|
||||||
<span v-if="caldavSaved" class="saved-msg">Saved!</span>
|
|
||||||
<button class="btn-secondary" @click="testCaldav" :disabled="testingCaldav">
|
|
||||||
{{ testingCaldav ? "Testing..." : "Test Connection" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="caldavTestResult" class="test-result" :class="{ success: caldavTestResult.success, error: !caldavTestResult.success }">
|
|
||||||
<template v-if="caldavTestResult.success">
|
|
||||||
{{ caldavTestResult.message }}
|
|
||||||
<div v-if="caldavTestResult.calendars?.length" class="test-calendars">
|
|
||||||
Calendars: {{ caldavTestResult.calendars.join(", ") }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>{{ caldavTestResult.error }}</template>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-section full-width">
|
<section class="settings-section full-width">
|
||||||
<h2>Web Search (SearXNG)</h2>
|
<h2>Web Search (SearXNG)</h2>
|
||||||
<template v-if="searxngConfigured">
|
<template v-if="searxngConfigured">
|
||||||
@@ -1499,35 +1630,6 @@ function formatUserDate(iso: string): string {
|
|||||||
token value, or copy the snippet now and paste your own where it says <code><your-token></code>.
|
token value, or copy the snippet now and paste your own where it says <code><your-token></code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Per-client config: server name + scope (persisted to localStorage) -->
|
|
||||||
<div class="mcp-client-config">
|
|
||||||
<label class="mcp-config-field">
|
|
||||||
<span class="mcp-config-label">Server name</span>
|
|
||||||
<input
|
|
||||||
v-model="mcpServerName"
|
|
||||||
type="text"
|
|
||||||
placeholder="scribe"
|
|
||||||
class="settings-input"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<span class="mcp-hint">
|
|
||||||
Local label Claude uses for this server. Examples: <code>scribe</code>, <code>scribe-dev</code>.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label class="mcp-config-field">
|
|
||||||
<span class="mcp-config-label">Scope</span>
|
|
||||||
<select v-model="mcpScope" class="settings-input">
|
|
||||||
<option value="user">user — available across all projects</option>
|
|
||||||
<option value="project">project — write to current repo's .mcp.json</option>
|
|
||||||
<option value="local">local — this machine + repo only</option>
|
|
||||||
</select>
|
|
||||||
<span class="mcp-hint">
|
|
||||||
Where Claude Code stores the server registration. Pick <code>project</code> to commit it
|
|
||||||
to a specific repo's <code>.mcp.json</code>.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mcp-client-tabs" role="tablist">
|
<div class="mcp-client-tabs" role="tablist">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1546,23 +1648,92 @@ function formatUserDate(iso: string): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Claude Code tab -->
|
<!-- Claude Code tab -->
|
||||||
<ol v-if="mcpClientTab === 'claude-code'">
|
<div v-if="mcpClientTab === 'claude-code'">
|
||||||
|
<p class="settings-description">
|
||||||
|
<strong>Recommended — install the Scribe plugin.</strong> One install wires up the MCP
|
||||||
|
connection, a session-start hook that surfaces your rules, and the Scribe process-skills.
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
<li>
|
<li>
|
||||||
Register the server with Claude Code:
|
Add the marketplace and install the plugin:
|
||||||
|
<div class="mcp-code-row">
|
||||||
|
<pre class="mcp-code">{{ pluginInstallCommands }}</pre>
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="copySnippet(pluginInstallCommands, 'plugin-install')">
|
||||||
|
{{ copiedSnippetKey === 'plugin-install' ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
When prompted, enter your <strong>Scribe base URL</strong> (<code>{{ origin }}</code>),
|
||||||
|
an <strong>API key</strong> (generate one above), and optionally a
|
||||||
|
<strong>project id</strong> to scope the session-start context.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Restart Claude Code — <code>/mcp</code> shows <code>scribe</code> connected and your
|
||||||
|
standing rules load at session start.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<details class="mcp-advanced">
|
||||||
|
<summary>Customize</summary>
|
||||||
|
|
||||||
|
<label class="mcp-config-field">
|
||||||
|
<span class="mcp-config-label">Plugin marketplace (git URL)</span>
|
||||||
|
<input
|
||||||
|
v-model="pluginMarketplaceUrl"
|
||||||
|
type="text"
|
||||||
|
:placeholder="serverMarketplaceUrl || 'https://git.example.com/you/Scribe.git'"
|
||||||
|
class="settings-input"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<span class="mcp-hint">
|
||||||
|
Pre-filled with this instance's repo. Override only if you host the plugin elsewhere.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="mcp-client-config">
|
||||||
|
<label class="mcp-config-field">
|
||||||
|
<span class="mcp-config-label">Server name</span>
|
||||||
|
<input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
|
||||||
|
<span class="mcp-hint">
|
||||||
|
Local label for the MCP-only path below. Examples: <code>scribe</code>, <code>scribe-dev</code>.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="mcp-config-field">
|
||||||
|
<span class="mcp-config-label">Scope</span>
|
||||||
|
<select v-model="mcpScope" class="settings-input">
|
||||||
|
<option value="user">user — available across all projects</option>
|
||||||
|
<option value="project">project — write to current repo's .mcp.json</option>
|
||||||
|
<option value="local">local — this machine + repo only</option>
|
||||||
|
</select>
|
||||||
|
<span class="mcp-hint">Where Claude Code stores the MCP-only registration.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mcp-hint" style="margin-top: 0.75rem;">
|
||||||
|
<strong>Connect the MCP only (no plugin).</strong> You won't get the session-start rule
|
||||||
|
push or the Scribe skills this way.
|
||||||
|
</p>
|
||||||
<div class="mcp-code-row">
|
<div class="mcp-code-row">
|
||||||
<pre class="mcp-code">{{ claudeCodeCommand }}</pre>
|
<pre class="mcp-code">{{ claudeCodeCommand }}</pre>
|
||||||
<button class="btn btn-secondary btn-sm" @click="copySnippet(claudeCodeCommand, 'cc-add')">
|
<button class="btn btn-secondary btn-sm" @click="copySnippet(claudeCodeCommand, 'cc-add')">
|
||||||
{{ copiedSnippetKey === 'cc-add' ? 'Copied' : 'Copy' }}
|
{{ copiedSnippetKey === 'cc-add' ? 'Copied' : 'Copy' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
<p class="mcp-hint">
|
||||||
<li>
|
Verify with <code>/mcp</code> — <code>{{ effectiveMcpName }}</code> should appear as connected.
|
||||||
Verify the connection by running <code>/mcp</code> inside Claude Code — <code>{{ effectiveMcpName }}</code> should appear as connected.
|
</p>
|
||||||
</li>
|
</details>
|
||||||
</ol>
|
</div>
|
||||||
|
|
||||||
<!-- Claude Desktop tab -->
|
<!-- Claude Desktop tab -->
|
||||||
<ol v-else-if="mcpClientTab === 'claude-desktop'">
|
<div v-else-if="mcpClientTab === 'claude-desktop'">
|
||||||
|
<label class="mcp-config-field">
|
||||||
|
<span class="mcp-config-label">Server name</span>
|
||||||
|
<input v-model="mcpServerName" type="text" placeholder="scribe" class="settings-input" spellcheck="false" />
|
||||||
|
<span class="mcp-hint">The key used for this server in the config JSON below.</span>
|
||||||
|
</label>
|
||||||
|
<ol>
|
||||||
<li>
|
<li>
|
||||||
Add this block to your Claude Desktop MCP config file
|
Add this block to your Claude Desktop MCP config file
|
||||||
(<code>~/Library/Application Support/Claude/claude_desktop_config.json</code> on macOS,
|
(<code>~/Library/Application Support/Claude/claude_desktop_config.json</code> on macOS,
|
||||||
@@ -1578,6 +1749,7 @@ function formatUserDate(iso: string): string {
|
|||||||
Restart Claude Desktop. The Scribe tools should appear in the available tools list.
|
Restart Claude Desktop. The Scribe tools should appear in the available tools list.
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1607,6 +1779,111 @@ function formatUserDate(iso: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Plugin marketplace</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
Git URL of the repo that ships this Scribe plugin (usually this app's own repo).
|
||||||
|
Shown to every user in <strong>MCP Access</strong> so the install command is
|
||||||
|
copyable. Example: https://git.example.com/you/Scribe.git
|
||||||
|
</p>
|
||||||
|
<div class="field url-field">
|
||||||
|
<label for="marketplace-url">Marketplace git URL</label>
|
||||||
|
<input
|
||||||
|
id="marketplace-url"
|
||||||
|
v-model="adminMarketplaceUrl"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://git.example.com/you/Scribe.git"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-save" @click="saveMarketplaceUrl" :disabled="savingMarketplaceUrl">
|
||||||
|
{{ savingMarketplaceUrl ? "Saving..." : "Save" }}
|
||||||
|
</button>
|
||||||
|
<span v-if="marketplaceUrlSaved" class="saved-msg">Saved!</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>Database maintenance</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
A daily <code>VACUUM (ANALYZE)</code> over the high-churn tables (logs, notifications,
|
||||||
|
tokens, notes, version history) — on top of Postgres autovacuum — to reclaim space left
|
||||||
|
by the nightly cleanup sweeps and keep query plans fresh. Runs at the hour below (UTC),
|
||||||
|
just after trash purge.
|
||||||
|
</p>
|
||||||
|
<div class="checkbox-field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="dbMaintEnabled" />
|
||||||
|
Run scheduled maintenance daily
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field url-field">
|
||||||
|
<label for="db-maint-hour">Run hour (UTC)</label>
|
||||||
|
<select id="db-maint-hour" v-model.number="dbMaintHour" class="input">
|
||||||
|
<option v-for="h in 24" :key="h - 1" :value="h - 1">
|
||||||
|
{{ String(h - 1).padStart(2, '0') }}:00
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-save" @click="saveDbMaintenance" :disabled="savingDbMaint">
|
||||||
|
{{ savingDbMaint ? "Saving..." : "Save" }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-save btn-secondary" @click="runDbMaintenanceNow" :disabled="runningDbMaint">
|
||||||
|
{{ runningDbMaint ? "Running..." : "Run now" }}
|
||||||
|
</button>
|
||||||
|
<span v-if="dbMaintSaved" class="saved-msg">Saved!</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="dbMaintLastRun" class="db-maint-last">
|
||||||
|
<span class="db-maint-last-label">
|
||||||
|
Last run {{ new Date(dbMaintLastRun.started_at).toLocaleString() }}
|
||||||
|
· {{ dbMaintLastRun.elapsed_ms }}ms
|
||||||
|
</span>
|
||||||
|
<ul class="db-maint-table-list">
|
||||||
|
<li v-for="t in dbMaintLastRun.tables" :key="t.table" :class="{ 'dm-failed': !t.ok }">
|
||||||
|
<code>{{ t.table }}</code>
|
||||||
|
<span class="dm-status">{{ t.ok ? `✓ ${t.elapsed_ms}ms` : `✗ ${t.error}` }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-health">
|
||||||
|
<h3 class="subsection-label">
|
||||||
|
Table health
|
||||||
|
<span v-if="dbHealth" class="db-health-total">· database {{ formatBytes(dbHealth.db_bytes) }}</span>
|
||||||
|
</h3>
|
||||||
|
<p class="field-hint">
|
||||||
|
Dead-tuple ratio is bloat — rows left by updates/deletes not yet reclaimed.
|
||||||
|
Above {{ DEAD_PCT_WARN }}% on a large table means autovacuum is falling behind;
|
||||||
|
consider adding it to the maintenance set.
|
||||||
|
</p>
|
||||||
|
<p v-if="loadingHealth && !dbHealth" class="field-hint">Loading…</p>
|
||||||
|
<div v-else-if="dbHealth" class="db-health-scroll">
|
||||||
|
<table class="db-health-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Table</th><th class="num">Size</th><th class="num">Live</th>
|
||||||
|
<th class="num">Dead</th><th class="num">Dead %</th>
|
||||||
|
<th>Last vacuum</th><th>Last analyze</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="t in dbHealth.tables" :key="t.table" :class="{ 'dh-warn': t.dead_pct >= DEAD_PCT_WARN }">
|
||||||
|
<td><code>{{ t.table }}</code></td>
|
||||||
|
<td class="num">{{ formatBytes(t.total_bytes) }}</td>
|
||||||
|
<td class="num">{{ t.live.toLocaleString() }}</td>
|
||||||
|
<td class="num">{{ t.dead.toLocaleString() }}</td>
|
||||||
|
<td class="num">{{ t.dead_pct }}%</td>
|
||||||
|
<td>{{ t.last_vacuum ? new Date(t.last_vacuum).toLocaleString() : "—" }}</td>
|
||||||
|
<td>{{ t.last_analyze ? new Date(t.last_analyze).toLocaleString() : "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-section full-width">
|
<section class="settings-section full-width">
|
||||||
<h2>Email / SMTP</h2>
|
<h2>Email / SMTP</h2>
|
||||||
<p class="section-desc">Configure SMTP to enable email notifications for all users.</p>
|
<p class="section-desc">Configure SMTP to enable email notifications for all users.</p>
|
||||||
@@ -2235,6 +2512,58 @@ function formatUserDate(iso: string): string {
|
|||||||
}
|
}
|
||||||
.btn-secondary:hover:not(:disabled) { background: var(--color-action-secondary-hover); }
|
.btn-secondary:hover:not(:disabled) { background: var(--color-action-secondary-hover); }
|
||||||
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
|
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
|
||||||
|
/* DB maintenance last-run summary */
|
||||||
|
.db-maint-last { margin-top: 1rem; }
|
||||||
|
.db-maint-last-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.db-maint-table-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.db-maint-table-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
.db-maint-table-list .dm-status { color: var(--color-text-muted); }
|
||||||
|
.db-maint-table-list li.dm-failed .dm-status { color: var(--color-danger); }
|
||||||
|
|
||||||
|
/* DB table-health readout */
|
||||||
|
.db-health { margin-top: 1.5rem; }
|
||||||
|
.db-health-total { color: var(--color-text-muted); font-weight: 400; }
|
||||||
|
.db-health-scroll { overflow-x: auto; margin-top: 0.5rem; }
|
||||||
|
.db-health-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.db-health-table th, .db-health-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.db-health-table th {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.db-health-table td.num, .db-health-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.db-health-table tr.dh-warn td { color: var(--color-warning); }
|
||||||
|
.db-health-table tr.dh-warn td:first-child code { color: var(--color-warning); }
|
||||||
.btn-warn:hover:not(:disabled) {
|
.btn-warn:hover:not(:disabled) {
|
||||||
background: var(--color-warning);
|
background: var(--color-warning);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -2287,41 +2616,6 @@ function formatUserDate(iso: string): string {
|
|||||||
accent-color: var(--color-primary);
|
accent-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CalDAV grid */
|
|
||||||
.caldav-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
|
||||||
gap: 0 1rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.caldav-grid { grid-template-columns: 1fr 1fr; }
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.caldav-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-result {
|
|
||||||
padding: 0.65rem 0.9rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.test-result.success {
|
|
||||||
background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg));
|
|
||||||
border: 1px solid var(--color-success);
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
.test-result.error {
|
|
||||||
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-bg));
|
|
||||||
border: 1px solid var(--color-danger);
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.test-calendars {
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search test */
|
/* Search test */
|
||||||
.url-chip {
|
.url-chip {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -2506,9 +2800,6 @@ function formatUserDate(iso: string): string {
|
|||||||
.smtp-grid {
|
.smtp-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.caldav-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Users panel */
|
/* Users panel */
|
||||||
@@ -3076,7 +3367,7 @@ function formatUserDate(iso: string): string {
|
|||||||
.settings-empty { opacity: 0.5; margin-top: 1rem; }
|
.settings-empty { opacity: 0.5; margin-top: 1rem; }
|
||||||
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
|
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
|
||||||
|
|
||||||
/* Fable MCP section */
|
/* Scribe MCP section */
|
||||||
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
|
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
|
||||||
.mcp-unavailable p { opacity: 0.7; }
|
.mcp-unavailable p { opacity: 0.7; }
|
||||||
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
|
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||||
@@ -3162,6 +3453,19 @@ function formatUserDate(iso: string): string {
|
|||||||
}
|
}
|
||||||
.mcp-code-row .mcp-code { margin-top: 0; }
|
.mcp-code-row .mcp-code { margin-top: 0; }
|
||||||
.mcp-code-row .btn-sm { white-space: nowrap; }
|
.mcp-code-row .btn-sm { white-space: nowrap; }
|
||||||
|
.mcp-advanced {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.mcp-advanced summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.mcp-advanced summary:hover { opacity: 1; }
|
||||||
|
.mcp-advanced[open] summary { margin-bottom: 0.5rem; }
|
||||||
.mcp-hint {
|
.mcp-hint {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
|||||||
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
||||||
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
||||||
import type { TaskStatus, TaskPriority } from "@/types/task";
|
import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||||
|
import type { TaskKind } from "@/types/note";
|
||||||
|
import { useSystemsStore } from "@/stores/systems";
|
||||||
|
import type { System } from "@/api/systems";
|
||||||
import type { Note } from "@/types/note";
|
import type { Note } from "@/types/note";
|
||||||
import type { Editor } from "@tiptap/vue-3";
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
@@ -31,6 +34,7 @@ import { Trash2 } from "lucide-vue-next";
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useTasksStore();
|
const store = useTasksStore();
|
||||||
|
const systemsStore = useSystemsStore();
|
||||||
const notesStore = useNotesStore();
|
const notesStore = useNotesStore();
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
@@ -41,6 +45,8 @@ const consolidatedAt = ref<string | null>(null);
|
|||||||
const tags = ref<string[]>([]);
|
const tags = ref<string[]>([]);
|
||||||
const status = ref<TaskStatus>("todo");
|
const status = ref<TaskStatus>("todo");
|
||||||
const priority = ref<TaskPriority>("none");
|
const priority = ref<TaskPriority>("none");
|
||||||
|
const kind = ref<TaskKind>("work");
|
||||||
|
const systemIds = ref<number[]>([]);
|
||||||
const dueDate = ref("");
|
const dueDate = ref("");
|
||||||
const projectId = ref<number | null>(null);
|
const projectId = ref<number | null>(null);
|
||||||
const milestoneId = ref<number | null>(null);
|
const milestoneId = ref<number | null>(null);
|
||||||
@@ -201,6 +207,8 @@ let savedDueDate = "";
|
|||||||
let savedProjectId: number | null = null;
|
let savedProjectId: number | null = null;
|
||||||
let savedMilestoneId: number | null = null;
|
let savedMilestoneId: number | null = null;
|
||||||
let savedParentId: number | null = null;
|
let savedParentId: number | null = null;
|
||||||
|
let savedKind: TaskKind = "work";
|
||||||
|
let savedSystemIds: number[] = [];
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
dirty.value =
|
dirty.value =
|
||||||
@@ -213,7 +221,22 @@ function markDirty() {
|
|||||||
dueDate.value !== savedDueDate ||
|
dueDate.value !== savedDueDate ||
|
||||||
projectId.value !== savedProjectId ||
|
projectId.value !== savedProjectId ||
|
||||||
milestoneId.value !== savedMilestoneId ||
|
milestoneId.value !== savedMilestoneId ||
|
||||||
parentId.value !== savedParentId;
|
parentId.value !== savedParentId ||
|
||||||
|
kind.value !== savedKind ||
|
||||||
|
JSON.stringify(systemIds.value) !== JSON.stringify(savedSystemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectSystems = computed<System[]>(() =>
|
||||||
|
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadSystems() {
|
||||||
|
if (!projectId.value) return;
|
||||||
|
try {
|
||||||
|
await systemsStore.fetchSystems(projectId.value);
|
||||||
|
} catch {
|
||||||
|
/* non-fatal — the systems picker just won't populate */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBodyUpdate(newVal: string) {
|
function onBodyUpdate(newVal: string) {
|
||||||
@@ -288,6 +311,8 @@ onMounted(async () => {
|
|||||||
const taskRec = store.currentTask as Record<string, unknown>;
|
const taskRec = store.currentTask as Record<string, unknown>;
|
||||||
projectId.value = (taskRec.project_id as number | null) ?? null;
|
projectId.value = (taskRec.project_id as number | null) ?? null;
|
||||||
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
|
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
|
||||||
|
kind.value = (taskRec.task_kind as TaskKind) ?? "work";
|
||||||
|
systemIds.value = ((taskRec.systems as Array<{ id: number }> | undefined) ?? []).map((s) => s.id);
|
||||||
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
||||||
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
||||||
parentSearchQuery.value = parentTitle.value;
|
parentSearchQuery.value = parentTitle.value;
|
||||||
@@ -305,6 +330,8 @@ onMounted(async () => {
|
|||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedParentId = parentId.value;
|
savedParentId = parentId.value;
|
||||||
|
savedKind = kind.value;
|
||||||
|
savedSystemIds = [...systemIds.value];
|
||||||
// Start in preview mode only if the task already has body content
|
// Start in preview mode only if the task already has body content
|
||||||
showPreview.value = body.value.trim().length > 0;
|
showPreview.value = body.value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -315,6 +342,7 @@ onMounted(async () => {
|
|||||||
if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId);
|
if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId);
|
||||||
if (route.query.parentId) parentId.value = Number(route.query.parentId);
|
if (route.query.parentId) parentId.value = Number(route.query.parentId);
|
||||||
}
|
}
|
||||||
|
loadSystems();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -333,6 +361,8 @@ async function save() {
|
|||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
parent_id: parentId.value,
|
parent_id: parentId.value,
|
||||||
recurrence_rule: recurrenceRule.value,
|
recurrence_rule: recurrenceRule.value,
|
||||||
|
kind: kind.value,
|
||||||
|
system_ids: systemIds.value,
|
||||||
};
|
};
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
await store.updateTask(taskId.value!, data);
|
await store.updateTask(taskId.value!, data);
|
||||||
@@ -346,6 +376,8 @@ async function save() {
|
|||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedParentId = parentId.value;
|
savedParentId = parentId.value;
|
||||||
|
savedKind = kind.value;
|
||||||
|
savedSystemIds = [...systemIds.value];
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Task saved");
|
toast.show("Task saved");
|
||||||
router.push(`/tasks/${taskId.value}`);
|
router.push(`/tasks/${taskId.value}`);
|
||||||
@@ -543,6 +575,16 @@ useEditorGuards(dirty, save);
|
|||||||
<option value="cancelled">Cancelled</option>
|
<option value="cancelled">Cancelled</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sb-field">
|
||||||
|
<label class="sb-label">Kind</label>
|
||||||
|
<select v-model="kind" @change="markDirty" class="sb-select">
|
||||||
|
<option value="work">Work</option>
|
||||||
|
<option value="issue">Issue</option>
|
||||||
|
<!-- 'plan' is retired (plans are milestones via start_planning);
|
||||||
|
offered only so legacy plan-tasks display their kind. -->
|
||||||
|
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div v-if="startedAt || completedAt" class="sb-timestamps">
|
<div v-if="startedAt || completedAt" class="sb-timestamps">
|
||||||
<div v-if="startedAt" class="sb-timestamp">
|
<div v-if="startedAt" class="sb-timestamp">
|
||||||
<span class="sb-ts-label">Started</span>
|
<span class="sb-ts-label">Started</span>
|
||||||
@@ -578,6 +620,16 @@ useEditorGuards(dirty, save);
|
|||||||
<label class="sb-label">Milestone</label>
|
<label class="sb-label">Milestone</label>
|
||||||
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
|
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="projectId" class="sb-field">
|
||||||
|
<label class="sb-label">Systems</label>
|
||||||
|
<div v-if="projectSystems.length" class="sb-systems">
|
||||||
|
<label v-for="s in projectSystems" :key="s.id" class="sb-system-opt">
|
||||||
|
<input type="checkbox" :value="s.id" v-model="systemIds" @change="markDirty" />
|
||||||
|
<span>{{ s.name }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-else class="sb-systems-empty">No systems in this project yet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Parent task -->
|
<!-- Parent task -->
|
||||||
<div class="sb-field">
|
<div class="sb-field">
|
||||||
@@ -955,6 +1007,12 @@ useEditorGuards(dirty, save);
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Systems multi-select (in sidebar) */
|
||||||
|
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
|
||||||
|
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--color-text); cursor: pointer; }
|
||||||
|
.sb-system-opt input { accent-color: var(--color-primary); cursor: pointer; }
|
||||||
|
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
|
|
||||||
/* Writing Assistant section (in sidebar) */
|
/* Writing Assistant section (in sidebar) */
|
||||||
.assist-section {
|
.assist-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "scribe",
|
||||||
|
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||||
|
"version": "0.1.12",
|
||||||
|
"author": { "name": "Bryan Van Deusen" },
|
||||||
|
"mcpServers": {
|
||||||
|
"scribe": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "${user_config.api_endpoint}/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer ${user_config.api_token}" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"userConfig": {
|
||||||
|
"api_endpoint": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Scribe base URL",
|
||||||
|
"description": "Base URL of your Scribe instance, no trailing slash (e.g. https://scribe.example.com)"
|
||||||
|
},
|
||||||
|
"api_token": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Scribe API key",
|
||||||
|
"description": "An fmcp_ API key from Settings → API Keys (read scope is enough for the session-start hook; write scope to use the tools)",
|
||||||
|
"sensitive": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Scribe plugin for Claude Code
|
||||||
|
|
||||||
|
Turns a self-hosted [Scribe](https://git.fabledsword.com/bvandeusen/FabledScribe)
|
||||||
|
instance into a first-class Claude Code extension:
|
||||||
|
|
||||||
|
- **MCP tools** over your notes, tasks, projects, milestones, systems, and
|
||||||
|
rulebook (the `scribe` server).
|
||||||
|
- **Session-start push channel** — a `SessionStart` hook injects your always-on
|
||||||
|
rules + active-project context so Scribe surfaces *without being asked*.
|
||||||
|
- **Universal process-skills** — brainstorm, systematic-debugging, TDD,
|
||||||
|
writing-plans, verification, receiving-code-review (replaces superpowers).
|
||||||
|
- **Your Scribe Processes as skills** — saved Processes are synced into local
|
||||||
|
`~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the
|
||||||
|
stub fetches the live procedure via `get_process`. Refreshed each session and
|
||||||
|
on demand with `/scribe:sync`.
|
||||||
|
|
||||||
|
It is designed so you can uninstall `superpowers` and disable auto-memory and
|
||||||
|
depend on neither.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
The plugin ships inside the Scribe app repo, so the marketplace *is* that repo —
|
||||||
|
you always get the plugin version that matches your Scribe instance.
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledScribe.git
|
||||||
|
/plugin install scribe@scribe-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
On install you'll be asked for:
|
||||||
|
|
||||||
|
| Setting | What |
|
||||||
|
|---|---|
|
||||||
|
| **Scribe base URL** | e.g. `https://scribe.example.com` (no trailing slash) |
|
||||||
|
| **Scribe API key** | an `fmcp_` key from **Settings → API Keys** (stored in your OS keychain) |
|
||||||
|
| **Active project id** | optional — numeric project id to scope the session-start context |
|
||||||
|
|
||||||
|
## What gets wired
|
||||||
|
|
||||||
|
- `plugin.json` `mcpServers` → the `scribe` MCP server at `<base URL>/mcp` (Bearer auth).
|
||||||
|
- `hooks/hooks.json` → SessionStart hook (`hooks/scribe_session_context.sh`),
|
||||||
|
**fail-open**: if Scribe is unreachable it injects nothing and never blocks
|
||||||
|
the session.
|
||||||
|
- `skills/` → the universal process-skills, surfaced by description match.
|
||||||
|
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
|
||||||
|
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
|
||||||
|
Processes (via `GET /api/plugin/processes`); also **fail-open**, and pruned to
|
||||||
|
match what exists in Scribe.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Set a `version` bump in `.claude-plugin/plugin.json` per release so clients
|
||||||
|
pick up changes.
|
||||||
|
- The session-start hook needs only a **read**-scoped key; the MCP tools need
|
||||||
|
**write** scope to create/update.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
description: Sync your Scribe stored Processes into auto-surfacing local skills
|
||||||
|
allowed-tools: Bash(bash:*), Bash(ls:*)
|
||||||
|
---
|
||||||
|
|
||||||
|
Regenerate the local skill stubs for your Scribe **Processes** so they auto-surface
|
||||||
|
this session. Each Process becomes a `~/.claude/skills/scribe-proc-<slug>/SKILL.md`
|
||||||
|
whose body calls `get_process(<name>)` for the live procedure.
|
||||||
|
|
||||||
|
This normally runs automatically at session start; use it after adding or editing
|
||||||
|
a Process when you want it available immediately (Claude Code live-detects the new
|
||||||
|
skill files within this session — no restart needed).
|
||||||
|
|
||||||
|
Run the bundled sync script, then list what was synced:
|
||||||
|
|
||||||
|
```
|
||||||
|
bash "${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh" && ls ~/.claude/skills 2>/dev/null | grep '^scribe-proc-' || echo "no Scribe process skills (no Processes, or Scribe unreachable)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Report which `scribe-proc-*` skills are now present.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_context.sh\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_autoinject.sh\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Scribe plugin — UserPromptSubmit push channel (knowledge auto-inject, Path A).
|
||||||
|
#
|
||||||
|
# On each user prompt, asks the operator's Scribe instance for a TITLE-FIRST
|
||||||
|
# awareness hint: the few notes that clear the per-user auto-inject gates
|
||||||
|
# (high-confidence threshold, margin gate, session dedup, top-k). Titles + ids
|
||||||
|
# only — never bodies; the agent calls get_note(id) to pull anything it judges
|
||||||
|
# relevant. Most turns inject nothing.
|
||||||
|
#
|
||||||
|
# Best-effort enrichment ONLY: unlike the SessionStart channel there is no
|
||||||
|
# static floor here. If the instance is unconfigured/unreachable, or anything
|
||||||
|
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
|
||||||
|
#
|
||||||
|
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code:
|
||||||
|
# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash
|
||||||
|
# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive)
|
||||||
|
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||||
|
#
|
||||||
|
# Session dedup: each surfaced note id is remembered in a per-session file so a
|
||||||
|
# note is injected at most once per session. Passed back as exclude_ids.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || exit 0
|
||||||
|
command -v curl >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
# UserPromptSubmit delivers a JSON event on stdin: { prompt, session_id, cwd, ... }
|
||||||
|
event=$(cat 2>/dev/null || true)
|
||||||
|
prompt=$(printf '%s' "$event" | jq -r '.prompt // empty' 2>/dev/null) || prompt=""
|
||||||
|
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
|
||||||
|
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
|
||||||
|
|
||||||
|
# Nothing to retrieve against.
|
||||||
|
[ -n "$prompt" ] || exit 0
|
||||||
|
|
||||||
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
||||||
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
||||||
|
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||||
|
case "$url" in *'${'*) url="" ;; esac
|
||||||
|
case "$token" in *'${'*) token="" ;; esac
|
||||||
|
# Unconfigured install → silent (auto-inject is pure enrichment).
|
||||||
|
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
||||||
|
|
||||||
|
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
||||||
|
q=$(printf '%s' "$prompt" | cut -c1-2000)
|
||||||
|
q_enc=$(printf '%s' "$q" | jq -rR '@uri' 2>/dev/null) || exit 0
|
||||||
|
|
||||||
|
# Resolve the working repo's remote so the server can scope to the bound project.
|
||||||
|
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||||
|
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
||||||
|
repo_q=""
|
||||||
|
if [ -n "$repo" ]; then
|
||||||
|
enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc=""
|
||||||
|
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Per-session dedup: ids already injected this session are skipped.
|
||||||
|
state_dir="${TMPDIR:-/tmp}/scribe-autoinject"
|
||||||
|
mkdir -p "$state_dir" 2>/dev/null || true
|
||||||
|
idfile=""
|
||||||
|
exclude_q=""
|
||||||
|
if [ -n "$session_id" ]; then
|
||||||
|
# session_id is an opaque token from Claude Code; keep only filename-safe chars.
|
||||||
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||||
|
idfile="$state_dir/${safe_sid}.ids"
|
||||||
|
if [ -f "$idfile" ]; then
|
||||||
|
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||||
|
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
body=$(curl -fsS --max-time 5 \
|
||||||
|
-H "Authorization: Bearer ${token}" \
|
||||||
|
"${url%/}/api/plugin/retrieve?q=${q_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
|
||||||
|
[ -n "$body" ] || exit 0
|
||||||
|
|
||||||
|
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
|
||||||
|
[ -n "$context" ] || exit 0
|
||||||
|
|
||||||
|
# Remember the surfaced ids so they aren't injected again this session.
|
||||||
|
if [ -n "$idfile" ]; then
|
||||||
|
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -n --arg c "$context" \
|
||||||
|
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
|
||||||
|
exit 0
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Scribe plugin — SessionStart push channel (two tiers + compaction re-grounding).
|
||||||
|
#
|
||||||
|
# Tier 1 (STATIC, always fires, no auth, no network): injects a bundled
|
||||||
|
# behavioral mandate (scribe_static_context.md) so a fresh session knows to
|
||||||
|
# reach for Scribe — record work, recall before acting — even when the instance
|
||||||
|
# is unreachable OR the API token never reached this hook. The latter is a known
|
||||||
|
# Claude Code gap: sensitive userConfig values aren't always exported to the
|
||||||
|
# hook subprocess, so the dynamic tier can silently get nothing. The static tier
|
||||||
|
# is the load-bearing floor that does not depend on the key or the network.
|
||||||
|
#
|
||||||
|
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
|
||||||
|
# for always-on rules + active-project context and appends it. Config comes from
|
||||||
|
# the plugin's userConfig, exported to hooks as:
|
||||||
|
# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash
|
||||||
|
# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive)
|
||||||
|
# The active project is resolved server-side from the working repo's git remote
|
||||||
|
# (see services/repo_bindings); bind each repo once with the bind_repo MCP tool.
|
||||||
|
#
|
||||||
|
# COMPACTION RE-GROUNDING: this hook is registered matcher-less, so it ALSO
|
||||||
|
# fires after a compaction (SessionStart input `source` == "compact"), when
|
||||||
|
# earlier turns have just been summarized and in-flight state is most at risk.
|
||||||
|
# On that source we lead with a banner telling the model to reload project +
|
||||||
|
# in-flight tasks from Scribe. (PreCompact is the wrong tool here — a host hook
|
||||||
|
# can't make the model flush, and can't know the in-flight task ids; the durable
|
||||||
|
# path is record-as-you-go + this post-compaction reload.)
|
||||||
|
#
|
||||||
|
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in
|
||||||
|
# hooks.json — sensitive values are kept in the keychain and never spliced into
|
||||||
|
# a hook command line, so the placeholder arrives unexpanded. The harness env
|
||||||
|
# vars above are the supported channel; SCRIBE_URL / SCRIBE_TOKEN override for
|
||||||
|
# the settings.json dogfooding path.
|
||||||
|
#
|
||||||
|
# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session. A *failed*
|
||||||
|
# dynamic fetch is surfaced as a short status line (not swallowed). A fully
|
||||||
|
# unconfigured install (no url AND no token) is the intended static-only mode
|
||||||
|
# and stays quiet.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely
|
||||||
|
|
||||||
|
here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0
|
||||||
|
|
||||||
|
# SessionStart delivers a JSON event on stdin; `source` is startup|resume|compact|clear.
|
||||||
|
event=$(cat 2>/dev/null || true)
|
||||||
|
source=$(printf '%s' "$event" | jq -r '.source // empty' 2>/dev/null) || source=""
|
||||||
|
|
||||||
|
out=""
|
||||||
|
# Append $1 to $out, separated by a horizontal rule when $out already has content.
|
||||||
|
append() { if [ -n "$out" ]; then out="${out}"$'\n\n---\n\n'"$1"; else out="$1"; fi; }
|
||||||
|
# Prepend $1 above $out (used for the compaction banner so it's seen first).
|
||||||
|
prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"; fi; }
|
||||||
|
|
||||||
|
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
|
||||||
|
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
|
||||||
|
|
||||||
|
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
||||||
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
||||||
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
||||||
|
|
||||||
|
# Guard against an unexpanded `${...}` placeholder reaching us as a literal — it
|
||||||
|
# would otherwise be sent as a garbage Bearer token and 401. Treat as unset.
|
||||||
|
case "$url" in *'${'*) url="" ;; esac
|
||||||
|
case "$token" in *'${'*) token="" ;; esac
|
||||||
|
|
||||||
|
dyn=""
|
||||||
|
status=""
|
||||||
|
if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
||||||
|
# Resolve the working repo's remote so the server can map it to a project.
|
||||||
|
repo_dir=${CLAUDE_PROJECT_DIR:-$PWD}
|
||||||
|
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
||||||
|
q=""
|
||||||
|
if [ -n "$repo" ]; then
|
||||||
|
enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc=""
|
||||||
|
[ -n "$enc" ] && q="?repo=${enc}"
|
||||||
|
fi
|
||||||
|
body=$(curl -fsS --max-time 8 \
|
||||||
|
-H "Authorization: Bearer ${token}" \
|
||||||
|
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
||||||
|
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
||||||
|
[ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
|
||||||
|
elif [ -n "$url" ] && [ -z "$token" ]; then
|
||||||
|
# Endpoint configured but token absent: the signature of the known Claude Code
|
||||||
|
# userConfig export gap (sensitive values not always reaching the hook).
|
||||||
|
status="> ⚠️ Scribe: live context disabled this session — the API token did not reach this hook (a known Claude Code plugin-config gap). Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$dyn" ] && append "$dyn"
|
||||||
|
[ -n "$status" ] && append "$status"
|
||||||
|
|
||||||
|
# Compaction re-grounding: lead with a reload banner when this fire is a compact.
|
||||||
|
if [ "$source" = "compact" ]; then
|
||||||
|
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Before continuing, reload your bearings from Scribe: re-pull the operator's binding rules with \`list_always_on_rules()\` (a compaction can summarize them out of context, leaving only generic harness defaults in their place), re-run \`enter_project()\` for the active project, check its open tasks and recent notes, and reconcile what you're mid-way through against what Scribe records. Don't trust half-remembered state — Scribe is the record."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Nothing at all to inject → stay silent.
|
||||||
|
[ -n "$out" ] || exit 0
|
||||||
|
|
||||||
|
jq -n --arg c "$out" \
|
||||||
|
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}'
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Scribe — your system of record
|
||||||
|
|
||||||
|
This environment has the **Scribe** plugin: the operator's self-hosted system
|
||||||
|
of record (notes, tasks, projects, milestones, rules) reachable through the
|
||||||
|
`scribe` MCP tools. Treat Scribe — **not local files** — as the source of truth
|
||||||
|
for the operator's work, and as your own working memory across sessions.
|
||||||
|
|
||||||
|
**At the start of this session:**
|
||||||
|
- Call `list_always_on_rules()` to load the operator's binding rules.
|
||||||
|
- If the working repo maps to a Scribe project (check `list_repo_bindings`),
|
||||||
|
call `enter_project(<id>)` to load that project's rules, open tasks, and
|
||||||
|
recent notes in one shot.
|
||||||
|
|
||||||
|
**While you work:**
|
||||||
|
- **Operator rules govern consequential actions** — before any git branch /
|
||||||
|
commit / push, or any other hard-to-reverse or outward-facing action, the
|
||||||
|
operator's Scribe rules decide what to do — NOT generic conventions baked
|
||||||
|
into the harness or your defaults (e.g. "branch before committing," "open a
|
||||||
|
feature branch per task," "push to a fork"). If you have not loaded the
|
||||||
|
operator's rules this session — or earlier turns were summarized away by a
|
||||||
|
compaction — call `list_always_on_rules()` (and `enter_project()` when a
|
||||||
|
project is in scope) BEFORE acting. When a loaded rule and a default habit
|
||||||
|
disagree, the rule wins; if no rule speaks to it, ask rather than assume.
|
||||||
|
- **Recall before acting** — before you answer anything about the operator's
|
||||||
|
work or start a task, `search` Scribe first; assume a related note, task, or
|
||||||
|
decision already exists. Concretely, reach for recall whenever a request
|
||||||
|
touches the operator's projects, people, places, prior decisions, or existing
|
||||||
|
work: check for an existing task before opening a new one, and for a prior
|
||||||
|
note/decision before re-deriving one. When a project is in scope (you entered
|
||||||
|
one), pass its id to `search` so results stay scoped to it. Treating Scribe as
|
||||||
|
the first place you look — not just somewhere you write — is what makes it a
|
||||||
|
trustworthy record.
|
||||||
|
- **Record as you go** — track work as Scribe tasks and log progress with
|
||||||
|
`add_task_log`. Always log when you **complete a task** and when you **hit or
|
||||||
|
discover a problem** — so changes of direction are captured, not just
|
||||||
|
successes. Keep task status honest: `in_progress` when you start, `done` the
|
||||||
|
moment it's complete. When you **fix** something — even in passing — record it
|
||||||
|
as its own issue (`create_task(kind="issue")`), not as a work-log line on an
|
||||||
|
unrelated open task.
|
||||||
|
- Do **not** keep the operator's rules, plans, or project notes in local
|
||||||
|
memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy.
|
||||||
|
- **Compact at clean seams** — because you record as you go, a context
|
||||||
|
compaction is safe: the durable record lives in Scribe, not the transcript.
|
||||||
|
After finishing a block of work in a long session, make sure in-flight state
|
||||||
|
is logged to Scribe, then tell the operator it's a good, safe moment to
|
||||||
|
`/compact` (name what you logged). You can't run it yourself — surface the
|
||||||
|
recommendation and let them decide. Suggest it at seams, not every turn.
|
||||||
|
|
||||||
|
If the Scribe tools are unavailable, say so rather than silently falling back
|
||||||
|
to local notes.
|
||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Scribe plugin — sync stored Processes into auto-surfacing local skills.
|
||||||
|
#
|
||||||
|
# Fetches `GET /api/plugin/processes` and writes one
|
||||||
|
# ~/.claude/skills/scribe-proc-<slug>/SKILL.md
|
||||||
|
# per Process. The stub's frontmatter `description` is the auto-surface trigger;
|
||||||
|
# its body tells Claude to call get_process(<name>) and follow the LIVE procedure
|
||||||
|
# from Scribe (the DB stays the single source of truth — the stub is a pointer).
|
||||||
|
#
|
||||||
|
# WHY LOCAL ~/.claude/skills (not the plugin dir): the plugin is git-cloned and
|
||||||
|
# identical on every install, so instance-specific stubs can't live inside it.
|
||||||
|
# Personal skills in ~/.claude/skills are live-detected by Claude Code within the
|
||||||
|
# session, so a freshly written stub auto-surfaces without a restart.
|
||||||
|
#
|
||||||
|
# TRIGGERS: this runs at SessionStart (alongside the context hook) so stubs stay
|
||||||
|
# fresh each session, and on demand via the `/scribe:sync` command.
|
||||||
|
#
|
||||||
|
# FAIL-OPEN & SILENT: never blocks a session; emits NOTHING on stdout (so it's
|
||||||
|
# safe as a second SessionStart hook). On any fetch failure it exits without
|
||||||
|
# touching existing stubs — a transient outage must not wipe the user's skills.
|
||||||
|
# Config mirrors the context hook (CLAUDE_PLUGIN_OPTION_* / SCRIBE_* override).
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || exit 0
|
||||||
|
command -v curl >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
||||||
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
||||||
|
# Guard against an unexpanded `${...}` placeholder arriving as a literal.
|
||||||
|
case "$url" in *'${'*) url="" ;; esac
|
||||||
|
case "$token" in *'${'*) token="" ;; esac
|
||||||
|
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
||||||
|
|
||||||
|
body=$(curl -fsS --max-time 8 \
|
||||||
|
-H "Authorization: Bearer ${token}" \
|
||||||
|
"${url%/}/api/plugin/processes" 2>/dev/null) || exit 0
|
||||||
|
[ -n "$body" ] || exit 0
|
||||||
|
|
||||||
|
count=$(printf '%s' "$body" | jq -r '.processes | length' 2>/dev/null) || exit 0
|
||||||
|
[ -n "$count" ] && [ "$count" != "null" ] || exit 0
|
||||||
|
|
||||||
|
skills_dir="${HOME}/.claude/skills"
|
||||||
|
mkdir -p "$skills_dir" 2>/dev/null || exit 0
|
||||||
|
|
||||||
|
# Slugs written this run — anything else under scribe-proc-* is pruned below.
|
||||||
|
managed=" "
|
||||||
|
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt "$count" ]; do
|
||||||
|
name=$(printf '%s' "$body" | jq -r ".processes[$i].name // empty" 2>/dev/null)
|
||||||
|
slug=$(printf '%s' "$body" | jq -r ".processes[$i].slug // empty" 2>/dev/null)
|
||||||
|
desc=$(printf '%s' "$body" | jq -r ".processes[$i].description // empty" 2>/dev/null)
|
||||||
|
i=$((i + 1))
|
||||||
|
[ -n "$slug" ] && [ -n "$name" ] || continue
|
||||||
|
|
||||||
|
dir="${skills_dir}/scribe-proc-${slug}"
|
||||||
|
mkdir -p "$dir" 2>/dev/null || continue
|
||||||
|
# description folded to one line — YAML scalar must not contain a newline.
|
||||||
|
desc=$(printf '%s' "$desc" | tr '\n' ' ')
|
||||||
|
{
|
||||||
|
printf -- '---\n'
|
||||||
|
printf 'name: scribe-proc-%s\n' "$slug"
|
||||||
|
printf 'description: %s\n' "$desc"
|
||||||
|
printf -- '---\n\n'
|
||||||
|
printf '<!-- GENERATED by the Scribe plugin (scribe_sync_processes.sh). Do not edit here; edit the Process in Scribe and re-sync with /scribe:sync. -->\n\n'
|
||||||
|
printf 'This is a saved **Scribe Process**: `%s`.\n\n' "$name"
|
||||||
|
printf 'Do not improvise it. Call `get_process("%s")` via the Scribe MCP server to fetch the live procedure, then follow the returned body verbatim — including any "clarify first" steps it contains.\n' "$name"
|
||||||
|
} > "${dir}/SKILL.md" 2>/dev/null || continue
|
||||||
|
managed="${managed}${slug} "
|
||||||
|
done
|
||||||
|
|
||||||
|
# Prune stubs whose Process no longer exists (scoped to our scribe-proc-* prefix).
|
||||||
|
for d in "${skills_dir}"/scribe-proc-*; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
slug=$(basename "$d"); slug=${slug#scribe-proc-}
|
||||||
|
case "$managed" in
|
||||||
|
*" $slug "*) : ;;
|
||||||
|
*) rm -rf "$d" 2>/dev/null ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: brainstorming
|
||||||
|
description: Use when exploring options or shaping a direction before committing — open up the solution space instead of jumping to the first idea. Triggers on "how should we approach X", "what are the options", weighing trade-offs, or any open-ended design question. Recall prior thinking first; capture the decision after.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brainstorming
|
||||||
|
|
||||||
|
Widen before you narrow. The first idea is rarely the best; the goal is a few
|
||||||
|
real options and a reasoned choice — not a single path defended after the fact.
|
||||||
|
|
||||||
|
## Recall first
|
||||||
|
|
||||||
|
`search` Scribe before generating from scratch — a prior decision, note, or
|
||||||
|
brainstorm on this often already exists. Build on it instead of repeating it.
|
||||||
|
|
||||||
|
## Open up
|
||||||
|
|
||||||
|
- Generate a few genuinely *different* options, not variations of one. Include at
|
||||||
|
least one you don't initially favor.
|
||||||
|
- For each: the core idea, what it's good at, and its main cost or risk — briefly.
|
||||||
|
- Resist converging until the space is actually explored.
|
||||||
|
|
||||||
|
## Then choose
|
||||||
|
|
||||||
|
- Recommend one, and say *why* — the trade-off that decided it, not just the pick.
|
||||||
|
- Surface the 1–2 places you made an interpretive call, so the operator can
|
||||||
|
redirect before it's baked in.
|
||||||
|
|
||||||
|
## Capture the decision
|
||||||
|
|
||||||
|
When a direction is chosen, record it in Scribe (`create_note`, e.g. tag
|
||||||
|
`decision`): the choice, the alternatives weighed, and the reason. That's what
|
||||||
|
keeps the same question from being re-litigated later — and what a future
|
||||||
|
session reads to understand *why*, not just *what*.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: systematic-debugging
|
||||||
|
description: Use when diagnosing a bug, failure, or unexpected behavior — investigate methodically instead of guessing. Triggers on "why is this failing/breaking", a stack trace, a flaky test, or any "it should work but doesn't". On resolution, capture the issue in Scribe so it isn't re-debugged from scratch.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Systematic debugging
|
||||||
|
|
||||||
|
Find the *root cause*, don't patch the symptom. Move one step at a time — a
|
||||||
|
guessed fix that "seems to work" often just moves the bug somewhere else.
|
||||||
|
|
||||||
|
## Recall first
|
||||||
|
|
||||||
|
Before digging in, `search` Scribe for the symptom — a prior issue
|
||||||
|
(`list_tasks(kind="issue")` or `search`) may already hold the cause and the fix.
|
||||||
|
Don't re-debug what's already solved.
|
||||||
|
|
||||||
|
## The loop
|
||||||
|
|
||||||
|
1. **Reproduce** — get a reliable, minimal repro. If you can't reproduce it, you
|
||||||
|
can't confirm you fixed it.
|
||||||
|
2. **Observe** — read the actual error / log / state. Don't theorize past the
|
||||||
|
data you have.
|
||||||
|
3. **Isolate** — narrow to the smallest failing case; change one variable at a
|
||||||
|
time so each result actually means something.
|
||||||
|
4. **Hypothesize → test** — state the single most likely cause, then test *that
|
||||||
|
one thing*. Confirm or rule it out before moving on; don't stack guesses.
|
||||||
|
5. **Root cause** — keep going until you can explain *why* it failed, not just
|
||||||
|
what made it stop. "It works now" without "because X" is unfinished.
|
||||||
|
6. **Fix + verify** — fix the cause, then re-run the repro to confirm it's gone.
|
||||||
|
|
||||||
|
## Capture the issue (so it's findable)
|
||||||
|
|
||||||
|
When resolved, record it in Scribe as its own issue (`create_task(kind="issue")`):
|
||||||
|
**symptom → root cause → fix → how it was verified** in the body, optionally
|
||||||
|
linked to the task it arose from (`arose_from_id`) and the subsystem it touches
|
||||||
|
(`system_ids`). Even a problem fixed in passing is worth two lines — that's how
|
||||||
|
the next person (or you) avoids re-deriving it. Record it discretely; don't bury
|
||||||
|
it as a work-log line on an unrelated open task. If the work was already tracked
|
||||||
|
as its own task, log the resolution there and set it `done`.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: using-scribe
|
||||||
|
description: Use at the START of every session, and before answering anything about the operator's work or starting any task — establishes the Scribe-first reflex. FIRST ACTION of a session: call list_always_on_rules() (and enter_project when a repo/project is in scope) to load the operator's binding rules. Then recall before acting, update over duplicate, plan in Scribe not in files.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using Scribe
|
||||||
|
|
||||||
|
Scribe is the operator's self-hosted system of record (notes, tasks, issues,
|
||||||
|
projects, milestones, systems) and rulebook, reachable
|
||||||
|
through the bundled `scribe` MCP server. Its value is mostly in what it
|
||||||
|
**already holds** — so make reading it a reflex, not something you wait to be
|
||||||
|
asked for.
|
||||||
|
|
||||||
|
## Do this first (every session)
|
||||||
|
|
||||||
|
**Pull the standing rules yourself — do not wait for them to be handed to you.**
|
||||||
|
At the start of a session, before substantive work, call
|
||||||
|
`list_always_on_rules()` to load the operator's always-on rules. If the working
|
||||||
|
repo maps to a Scribe project (you're in a known repo, or `list_repo_bindings`
|
||||||
|
shows a binding), call `enter_project(id)` instead/as-well — it returns the
|
||||||
|
project plus its applicable rules, open tasks, and recent notes in one shot.
|
||||||
|
|
||||||
|
Do this actively. A SessionStart hook *may* also inject a rule index, but treat
|
||||||
|
that as a bonus, not a precondition: it can be absent (e.g. when the instance is
|
||||||
|
unreachable, or the token didn't reach the hook), so the reliable path is this
|
||||||
|
explicit pull. Rules loaded this way are **binding** for the session.
|
||||||
|
|
||||||
|
## Scribe holds these functions — don't keep a second copy
|
||||||
|
|
||||||
|
This plugin makes Scribe the home for the operator's **rules, recall, and
|
||||||
|
planning** — the jobs Claude's native auto-memory would otherwise do. When the
|
||||||
|
plugin is present, route those jobs to Scribe and **do not also write them to
|
||||||
|
native memory**: codify rules with `create_rule` / `create_project_rule`,
|
||||||
|
capture durable knowledge as Scribe notes, and keep plans in Scribe milestones
|
||||||
|
(via `start_planning`) — not in `MEMORY.md` or `CLAUDE.md`. One copy, in Scribe; let any existing local
|
||||||
|
memory shrink as Scribe takes over. Don't maintain both stores in parallel.
|
||||||
|
|
||||||
|
Two constraints on *how* that's achieved:
|
||||||
|
|
||||||
|
- **Steer behavior; never flip a native switch.** The plugin must work with
|
||||||
|
native auto-memory at its default (ON). Never tell the operator to set
|
||||||
|
`autoMemoryEnabled:false` or otherwise disable a built-in function to make
|
||||||
|
Scribe "win" — a setting the operator may not know was changed (and wouldn't
|
||||||
|
know to restore) is exactly the hidden breakage to avoid. You replace memory's
|
||||||
|
functions by *doing the work in Scribe*, not by turning memory off.
|
||||||
|
- **A Scribe-shaped hole is acceptable.** If the plugin is later removed, the
|
||||||
|
operator recovers context over time — that's fine. You do **not** need to keep
|
||||||
|
native memory as a self-sufficient fallback. The only thing to avoid is
|
||||||
|
breakage caused by a settings change the operator didn't make knowingly.
|
||||||
|
|
||||||
|
## The reflex
|
||||||
|
|
||||||
|
1. **Recall before acting.** Before answering a question about the operator's
|
||||||
|
work, or starting a task, `search` Scribe (and `list_tasks` / `list_notes`)
|
||||||
|
for related prior work — an existing task, decision, or note — instead of
|
||||||
|
re-deriving it or opening a duplicate. When a project is in scope, pass its
|
||||||
|
`project_id` so results stay scoped.
|
||||||
|
|
||||||
|
2. **Standing rules are binding.** Load them via `list_always_on_rules()` at
|
||||||
|
session start (see "Do this first"); treat every one as binding. Pull a
|
||||||
|
rule's full statement with `get_rule(id)` when it's about to bite. When a
|
||||||
|
project is in scope, `enter_project(id)` also returns its applicable rules.
|
||||||
|
|
||||||
|
3. **Update over duplicate.** When recording, prefer updating an existing
|
||||||
|
note/rule/task over creating a new one. Search first; revise what's there.
|
||||||
|
|
||||||
|
4. **Plans live in Scribe.** For non-trivial work call `start_planning(project_id,
|
||||||
|
title)` FIRST — it creates a milestone whose `body` holds the design; each
|
||||||
|
step is its own task under that milestone (`create_task(milestone_id=...)`),
|
||||||
|
progress goes in work-logs (`add_task_log`). Read it back with `get_milestone`.
|
||||||
|
Do not write plans/specs to local `.md` files.
|
||||||
|
|
||||||
|
5. **Keep state honest.** Set a task `in_progress` when you start it, `done` the
|
||||||
|
moment it's complete; log progress as you go.
|
||||||
|
|
||||||
|
6. **Fixes are issues, not work-logs.** When you fix a problem — even one solved
|
||||||
|
in passing — record it as its own issue (`create_task(kind="issue")`) with
|
||||||
|
symptom → root cause → fix, optionally linked to the task it arose from
|
||||||
|
(`arose_from_id`) and the subsystem it touches (`system_ids`). Don't bury a
|
||||||
|
fix as a work-log line on whatever task happened to be open.
|
||||||
|
|
||||||
|
## Stay inside the active project's scope
|
||||||
|
|
||||||
|
Once a project is in scope — you called `enter_project`, or the working repo is
|
||||||
|
bound — confine the session to it:
|
||||||
|
|
||||||
|
- **Pass that `project_id` to every read** (`search`, `list_tasks`,
|
||||||
|
`list_notes`). An unscoped read bleeds every other project's work into your
|
||||||
|
context.
|
||||||
|
- **Only reference or offer work on the in-scope project.** Don't surface,
|
||||||
|
suggest, or start work on other projects unless the operator explicitly widens
|
||||||
|
scope.
|
||||||
|
- If something clearly belongs to a *different* project, say so and **ask before
|
||||||
|
switching** — never silently operate cross-project.
|
||||||
|
|
||||||
|
## Where a new rule goes
|
||||||
|
|
||||||
|
When codifying a rule, pick its home by **who it should bind** — and keep
|
||||||
|
shared homes general:
|
||||||
|
|
||||||
|
- **Always-on rulebook** (`create_rule` in an `always_on` rulebook) — universal
|
||||||
|
norms that bind *every* project. Cross-project standards only.
|
||||||
|
- **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a
|
||||||
|
reusable, *themed* module of general rules that binds only projects that opt
|
||||||
|
in (e.g. a design system → visual apps). Themed, but still project-agnostic.
|
||||||
|
- **Project rule** (`create_project_rule`) — anything specific to one project
|
||||||
|
(its files, paths, quirks).
|
||||||
|
|
||||||
|
Both rulebook tiers are shared, so their rules stay general; they differ in
|
||||||
|
**reach** (all vs opt-in), not generality. Names one project's specifics →
|
||||||
|
project rule; a standard a category shares → subscribed rulebook; a universal
|
||||||
|
norm → always-on rulebook. Never put project-specific detail in a shared
|
||||||
|
rulebook — it leaks to every other project that gets it.
|
||||||
|
|
||||||
|
## Other Scribe process-skills
|
||||||
|
|
||||||
|
This plugin also ships focused process-skills — writing-plans, systematic
|
||||||
|
debugging, verification, and brainstorming. Reach for the matching one when its
|
||||||
|
situation arises, the same way you reach for this skill.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
name: verification
|
||||||
|
description: Use before claiming a task is done or a change works — confirm it actually does, then record that you did. Triggers when you're about to report completion, mark a task done, or say "it works" / "fixed". Guards against declaring success on unverified work.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Verification before completion
|
||||||
|
|
||||||
|
"Done" means verified, not "written." Before you claim a change works or set a
|
||||||
|
task `done`, confirm it against reality and record what you checked.
|
||||||
|
|
||||||
|
## Verify against reality
|
||||||
|
|
||||||
|
- Exercise the actual behavior — run it, test it, observe the output. Prefer the
|
||||||
|
real path over "it should work by inspection."
|
||||||
|
- Check the thing the user actually asked for, not a proxy for it.
|
||||||
|
- If you *can't* verify something (no environment, needs hardware, needs the
|
||||||
|
operator), say so explicitly — name what's unverified rather than letting it
|
||||||
|
read as passed.
|
||||||
|
|
||||||
|
## Record the result, then close
|
||||||
|
|
||||||
|
- Log what you verified, and how, to the task with `add_task_log` — the check is
|
||||||
|
part of the record, not a private step.
|
||||||
|
- Only then set the task `done`. Never mark finished work you haven't confirmed,
|
||||||
|
and never leave confirmed work sitting at `in_progress`.
|
||||||
|
- If verification surfaced a problem, capture it as its own issue
|
||||||
|
(`create_task(kind="issue")`) and keep the task open — a found problem is a
|
||||||
|
pivot to record, not something to quietly skip.
|
||||||
|
|
||||||
|
## Honesty over optimism
|
||||||
|
|
||||||
|
A truthful "verified X; could not verify Y" is worth more than a confident
|
||||||
|
"done." The record is only useful if `done` reliably means done.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
name: writing-plans
|
||||||
|
description: Use before starting any non-trivial or multi-step piece of work — produce a clear plan BEFORE diving in. Triggers when the user asks you to plan, design an approach, scope an effort, or tackle work big enough to need ordered steps. The plan lives in a Scribe milestone (via start_planning), not a local file.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Writing plans
|
||||||
|
|
||||||
|
A plan is **how** you'll execute a chunk of work — the design plus an ordered
|
||||||
|
set of steps — written *before* you start, so the approach is reviewable and the
|
||||||
|
work stays trackable.
|
||||||
|
|
||||||
|
## Start the plan in Scribe, not a file
|
||||||
|
|
||||||
|
For non-trivial work, call **`start_planning(project_id, title)` FIRST** —
|
||||||
|
before any design or implementation. It creates a **milestone** (the plan
|
||||||
|
container) seeded with a design template and returns the milestone id plus the
|
||||||
|
project's applicable rules. The plan lives in that milestone:
|
||||||
|
|
||||||
|
- The **design/intent** goes in the milestone `body` — edit it with
|
||||||
|
`update_milestone(milestone_id, body=...)`.
|
||||||
|
- Each **step** is its own task under the milestone — create it with
|
||||||
|
`create_task(milestone_id=<that milestone>)` and track it with status +
|
||||||
|
`add_task_log`. Steps are first-class tasks, **not** checkboxes in the body.
|
||||||
|
- Read the whole plan back with `get_milestone` (body + its step-tasks).
|
||||||
|
|
||||||
|
**Do not** write plans or specs to local `.md` files — the milestone is the
|
||||||
|
record, not a file on disk. (The old `kind=plan` task is retired; `start_planning`
|
||||||
|
no longer creates one.)
|
||||||
|
|
||||||
|
Before designing from scratch, **recall**: `search` Scribe for a related prior
|
||||||
|
plan or decision. Often the thinking (or half of it) already exists.
|
||||||
|
|
||||||
|
## What a good plan contains
|
||||||
|
|
||||||
|
- **Goal** — what "done" looks like, and why, in a sentence or two (milestone body).
|
||||||
|
- **Approach** — the key design decisions and the trade-offs you chose, briefly
|
||||||
|
(milestone body).
|
||||||
|
- **Steps** — an ordered set of step-tasks under the milestone, each small enough
|
||||||
|
to verify on its own; note which files/areas each touches.
|
||||||
|
- **Verification** — how you'll know it actually works (a test, CI, an
|
||||||
|
observable behavior), not just "it's written."
|
||||||
|
|
||||||
|
## While executing
|
||||||
|
|
||||||
|
- Keep the plan **honest**: drive each step-task's status (todo →
|
||||||
|
in_progress → done) as it lands; record decisions, findings, and pivots with
|
||||||
|
`add_task_log` on the relevant step rather than silently rewriting the body.
|
||||||
|
- If reality diverges from the plan, **update the milestone body** — a design
|
||||||
|
that no longer matches what you're doing is worse than none. Add or re-scope
|
||||||
|
step-tasks as the work changes.
|
||||||
|
- Mark the milestone `done` when its steps are complete.
|
||||||
|
|
||||||
|
## Match depth to the work
|
||||||
|
|
||||||
|
A two-step change deserves a two-line plan; a multi-day effort deserves a
|
||||||
|
fleshed-out milestone body and several step-tasks. Don't over-plan the trivial,
|
||||||
|
and don't under-plan something that will sprawl. The point is a shared,
|
||||||
|
reviewable intent — not ceremony.
|
||||||
+5
-1
@@ -3,7 +3,7 @@ requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "fabledassistant"
|
name = "scribe"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP"
|
description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
@@ -21,6 +21,7 @@ dependencies = [
|
|||||||
"APScheduler>=3.10,<4.0",
|
"APScheduler>=3.10,<4.0",
|
||||||
"mcp[cli]>=1.0",
|
"mcp[cli]>=1.0",
|
||||||
"fastembed>=0.4",
|
"fastembed>=0.4",
|
||||||
|
"pgvector>=0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -36,6 +37,9 @@ where = ["src"]
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
markers = [
|
||||||
|
"integration: requires a real Postgres database (runs only in the CI integration lane)",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ async def run(args) -> None:
|
|||||||
print(f" - {r['title']}: {preview}{empty_flag}")
|
print(f" - {r['title']}: {preview}{empty_flag}")
|
||||||
return
|
return
|
||||||
|
|
||||||
from fabledassistant.services import rulebooks as rb_service
|
from scribe.services import rulebooks as rb_service
|
||||||
|
|
||||||
existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title)
|
existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title)
|
||||||
if existing is not None and not args.force:
|
if existing is not None and not args.force:
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
"""Typed-entity MCP tools: person, place, list.
|
|
||||||
|
|
||||||
These are notes with a non-'note' note_type and type-specific JSON metadata
|
|
||||||
stored in the entity_meta column. Three tools per type — list, create, update.
|
|
||||||
For get and delete, use get_note / delete_note (typed entities
|
|
||||||
share the Note model).
|
|
||||||
|
|
||||||
The wrappers translate typed-field kwargs into the entity_meta dict shape that
|
|
||||||
KnowledgeView.vue and services/knowledge.py expect.
|
|
||||||
|
|
||||||
Lists: a list entity stores its items in entity_meta["list_items"] as a list
|
|
||||||
of {text, checked} dicts. The create/update tools take a simpler `items` list
|
|
||||||
of plain strings for ergonomics; checked-state is reset to False on each call.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
|
||||||
from fabledassistant.services import knowledge as knowledge_svc
|
|
||||||
from fabledassistant.services import notes as notes_svc
|
|
||||||
|
|
||||||
|
|
||||||
async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict:
|
|
||||||
"""Common list query for a typed entity."""
|
|
||||||
uid = current_user_id()
|
|
||||||
items, total = await knowledge_svc.query_knowledge(
|
|
||||||
user_id=uid,
|
|
||||||
note_type=note_type,
|
|
||||||
tags=[tag] if tag else [],
|
|
||||||
sort="modified",
|
|
||||||
q=q or None,
|
|
||||||
limit=max(1, min(limit, 100)),
|
|
||||||
offset=0,
|
|
||||||
)
|
|
||||||
# Map "items" key to a type-specific key for caller clarity.
|
|
||||||
plural = {"person": "persons", "place": "places", "list": "lists"}[note_type]
|
|
||||||
return {plural: items, "total": total}
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_entity(note_type: str, name: str, entity_meta: dict,
|
|
||||||
tags: list[str] | None = None) -> dict:
|
|
||||||
"""Create a typed note with the given metadata."""
|
|
||||||
uid = current_user_id()
|
|
||||||
note = await notes_svc.create_note(
|
|
||||||
uid,
|
|
||||||
title=name,
|
|
||||||
note_type=note_type,
|
|
||||||
entity_meta=entity_meta or None,
|
|
||||||
tags=tags,
|
|
||||||
)
|
|
||||||
return note.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
async def _update_entity(entity_id: int, note_type: str, name: str,
|
|
||||||
meta_updates: dict) -> dict:
|
|
||||||
"""Merge updates into entity_meta and (optionally) update the title."""
|
|
||||||
uid = current_user_id()
|
|
||||||
note = await notes_svc.get_note(uid, entity_id)
|
|
||||||
if note is None or note.note_type != note_type:
|
|
||||||
raise ValueError(f"{note_type} {entity_id} not found")
|
|
||||||
new_meta = dict(note.entity_meta or {})
|
|
||||||
new_meta.update(meta_updates)
|
|
||||||
fields: dict = {"entity_meta": new_meta}
|
|
||||||
if name:
|
|
||||||
fields["title"] = name
|
|
||||||
updated = await notes_svc.update_note(uid, entity_id, **fields)
|
|
||||||
return updated.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Person ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_PERSON_FIELDS = ("relationship", "email", "phone", "birthday",
|
|
||||||
"organization", "address")
|
|
||||||
|
|
||||||
|
|
||||||
async def list_persons(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
|
||||||
"""List people in the user's knowledge base.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
q: Free-text search across name + body (optional).
|
|
||||||
tag: Filter to a single tag (optional).
|
|
||||||
limit: Max results (1-100).
|
|
||||||
"""
|
|
||||||
return await _list_by_type("person", q, tag, limit)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_person(
|
|
||||||
name: str,
|
|
||||||
relationship: str = "",
|
|
||||||
email: str = "",
|
|
||||||
phone: str = "",
|
|
||||||
birthday: str = "",
|
|
||||||
organization: str = "",
|
|
||||||
address: str = "",
|
|
||||||
tags: list[str] | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Create a person in the user's knowledge base.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Person's name (required).
|
|
||||||
relationship: How the user knows them (e.g. "colleague", "friend").
|
|
||||||
email / phone / birthday (YYYY-MM-DD) / organization / address: optional.
|
|
||||||
tags: Plain-string tags, no # prefix.
|
|
||||||
"""
|
|
||||||
meta = {f: v for f, v in (
|
|
||||||
("relationship", relationship), ("email", email), ("phone", phone),
|
|
||||||
("birthday", birthday), ("organization", organization),
|
|
||||||
("address", address),
|
|
||||||
) if v}
|
|
||||||
return await _create_entity("person", name, meta, tags)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_person(
|
|
||||||
person_id: int,
|
|
||||||
name: str = "",
|
|
||||||
relationship: str = "",
|
|
||||||
email: str = "",
|
|
||||||
phone: str = "",
|
|
||||||
birthday: str = "",
|
|
||||||
organization: str = "",
|
|
||||||
address: str = "",
|
|
||||||
) -> dict:
|
|
||||||
"""Update a person. Only explicitly provided fields are changed.
|
|
||||||
|
|
||||||
To clear a field, pass an explicit space character (this preserves the
|
|
||||||
fable-mcp empty-string-means-omit convention).
|
|
||||||
"""
|
|
||||||
meta_updates = {f: v for f, v in (
|
|
||||||
("relationship", relationship), ("email", email), ("phone", phone),
|
|
||||||
("birthday", birthday), ("organization", organization),
|
|
||||||
("address", address),
|
|
||||||
) if v}
|
|
||||||
return await _update_entity(person_id, "person", name, meta_updates)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Place ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def list_places(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
|
||||||
"""List places (cafes, offices, addresses) in the user's knowledge base."""
|
|
||||||
return await _list_by_type("place", q, tag, limit)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_place(
|
|
||||||
name: str,
|
|
||||||
address: str = "",
|
|
||||||
phone: str = "",
|
|
||||||
hours: str = "",
|
|
||||||
website: str = "",
|
|
||||||
category: str = "",
|
|
||||||
tags: list[str] | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Create a place in the user's knowledge base.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Place name (required).
|
|
||||||
address / phone / hours / website / category: optional.
|
|
||||||
tags: Plain-string tags, no # prefix.
|
|
||||||
"""
|
|
||||||
meta = {f: v for f, v in (
|
|
||||||
("address", address), ("phone", phone), ("hours", hours),
|
|
||||||
("website", website), ("category", category),
|
|
||||||
) if v}
|
|
||||||
return await _create_entity("place", name, meta, tags)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_place(
|
|
||||||
place_id: int,
|
|
||||||
name: str = "",
|
|
||||||
address: str = "",
|
|
||||||
phone: str = "",
|
|
||||||
hours: str = "",
|
|
||||||
website: str = "",
|
|
||||||
category: str = "",
|
|
||||||
) -> dict:
|
|
||||||
"""Update a place. Only explicitly provided fields are changed."""
|
|
||||||
meta_updates = {f: v for f, v in (
|
|
||||||
("address", address), ("phone", phone), ("hours", hours),
|
|
||||||
("website", website), ("category", category),
|
|
||||||
) if v}
|
|
||||||
return await _update_entity(place_id, "place", name, meta_updates)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── List ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
async def list_lists(q: str = "", tag: str = "", limit: int = 25) -> dict:
|
|
||||||
"""List checklists in the user's knowledge base."""
|
|
||||||
return await _list_by_type("list", q, tag, limit)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_list(
|
|
||||||
name: str,
|
|
||||||
category: str = "",
|
|
||||||
items: list[str] | None = None,
|
|
||||||
tags: list[str] | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Create a checklist (a list-type entity).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: List name (required).
|
|
||||||
category: Optional category label (e.g. "shopping", "packing").
|
|
||||||
items: Initial item texts. All items start unchecked.
|
|
||||||
tags: Plain-string tags, no # prefix.
|
|
||||||
"""
|
|
||||||
meta: dict = {}
|
|
||||||
if category:
|
|
||||||
meta["category"] = category
|
|
||||||
if items:
|
|
||||||
meta["list_items"] = [{"text": t, "checked": False} for t in items]
|
|
||||||
return await _create_entity("list", name, meta, tags)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_list(
|
|
||||||
list_id: int,
|
|
||||||
name: str = "",
|
|
||||||
category: str = "",
|
|
||||||
items: list[str] | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Update a checklist.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
list_id: ID of the list to update.
|
|
||||||
name: New title (optional).
|
|
||||||
category: New category (optional).
|
|
||||||
items: REPLACES the entire item set with these texts (all reset to
|
|
||||||
unchecked). Omit (None) to leave items unchanged. Pass an empty
|
|
||||||
list to clear all items.
|
|
||||||
"""
|
|
||||||
meta_updates: dict = {}
|
|
||||||
if category:
|
|
||||||
meta_updates["category"] = category
|
|
||||||
if items is not None:
|
|
||||||
meta_updates["list_items"] = [
|
|
||||||
{"text": t, "checked": False} for t in items
|
|
||||||
]
|
|
||||||
return await _update_entity(list_id, "list", name, meta_updates)
|
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
|
||||||
for fn in (
|
|
||||||
list_persons, create_person, update_person,
|
|
||||||
list_places, create_place, update_place,
|
|
||||||
list_lists, create_list, update_list,
|
|
||||||
):
|
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"""Calendar event MCP tools — new in Phase 3.
|
|
||||||
|
|
||||||
Events were previously only an internal-LLM tool; the MCP surface didn't have
|
|
||||||
them. Wraps services/events.py.
|
|
||||||
|
|
||||||
Date/time inputs are split: start_date (YYYY-MM-DD) + start_time (HH:MM) get
|
|
||||||
combined into a naive datetime; the service layer interprets it in the user's
|
|
||||||
local timezone. duration_minutes=0 ⇒ point event (NULL duration). The
|
|
||||||
LLM-era expected_weekday verification check is intentionally not replicated —
|
|
||||||
Claude doesn't need it.
|
|
||||||
|
|
||||||
For update, sentinels:
|
|
||||||
- title="" / location="" / description="" → leave unchanged
|
|
||||||
- start_date="" / start_time="" → leave unchanged (both must be provided to
|
|
||||||
move the event)
|
|
||||||
- duration_minutes=-1 → leave unchanged; 0 means "set to point event"
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
|
||||||
from fabledassistant.services import events as events_svc
|
|
||||||
from fabledassistant.services import trash as trash_svc
|
|
||||||
|
|
||||||
|
|
||||||
def _combine(start_date: str, start_time: str) -> datetime:
|
|
||||||
"""Combine YYYY-MM-DD + HH:MM into a naive datetime.
|
|
||||||
|
|
||||||
The events service interprets naive datetimes for create/update against
|
|
||||||
the user's configured timezone, so we don't attach tzinfo here.
|
|
||||||
"""
|
|
||||||
t = start_time or "00:00"
|
|
||||||
return datetime.fromisoformat(f"{start_date}T{t}:00")
|
|
||||||
|
|
||||||
|
|
||||||
def _day_range_utc(date_from: str, date_to: str) -> tuple[datetime, datetime]:
|
|
||||||
"""Return a UTC datetime range [start_of_date_from, end_of_date_to).
|
|
||||||
|
|
||||||
Event.start_dt is stored timezone-aware in the DB; comparing it against a
|
|
||||||
naive datetime raises TypeError. We anchor the range in UTC, which is a
|
|
||||||
reasonable default — refining to the user's local timezone for the
|
|
||||||
range boundaries is a separate improvement.
|
|
||||||
"""
|
|
||||||
start = datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
|
||||||
# `date_to` is inclusive at the day level — bump by 24h so events later
|
|
||||||
# on date_to are included.
|
|
||||||
end = (
|
|
||||||
datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc)
|
|
||||||
+ timedelta(days=1)
|
|
||||||
)
|
|
||||||
return start, end
|
|
||||||
|
|
||||||
|
|
||||||
def _event_dict(event) -> dict:
|
|
||||||
"""Render an Event model to a dict, handling list_events (already dicts)."""
|
|
||||||
return event if isinstance(event, dict) else event.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_events(date_from: str, date_to: str) -> dict:
|
|
||||||
"""List events between date_from and date_to (YYYY-MM-DD, both inclusive at
|
|
||||||
the day level — `date_to` is interpreted as end-of-that-day).
|
|
||||||
|
|
||||||
Recurring events are expanded into individual occurrences within the range.
|
|
||||||
"""
|
|
||||||
uid = current_user_id()
|
|
||||||
start, end = _day_range_utc(date_from, date_to)
|
|
||||||
rows = await events_svc.list_events(uid, start, end)
|
|
||||||
return {"events": [_event_dict(e) for e in rows], "total": len(rows)}
|
|
||||||
|
|
||||||
|
|
||||||
async def create_event(
|
|
||||||
title: str,
|
|
||||||
start_date: str,
|
|
||||||
start_time: str = "00:00",
|
|
||||||
duration_minutes: int = 0,
|
|
||||||
all_day: bool = False,
|
|
||||||
location: str = "",
|
|
||||||
description: str = "",
|
|
||||||
) -> dict:
|
|
||||||
"""Create a calendar event.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: Event title (required).
|
|
||||||
start_date: YYYY-MM-DD.
|
|
||||||
start_time: HH:MM (24-hour). Ignored when all_day=True.
|
|
||||||
duration_minutes: 0 for a point event (no duration); otherwise minutes.
|
|
||||||
all_day: True to make this an all-day event.
|
|
||||||
location: Optional location string.
|
|
||||||
description: Optional longer description.
|
|
||||||
"""
|
|
||||||
uid = current_user_id()
|
|
||||||
event = await events_svc.create_event(
|
|
||||||
uid,
|
|
||||||
title=title,
|
|
||||||
start_dt=_combine(start_date, start_time),
|
|
||||||
duration_minutes=duration_minutes or None,
|
|
||||||
all_day=all_day,
|
|
||||||
location=location,
|
|
||||||
description=description,
|
|
||||||
)
|
|
||||||
return event.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_event(event_id: int) -> dict:
|
|
||||||
"""Fetch a single event by ID."""
|
|
||||||
uid = current_user_id()
|
|
||||||
event = await events_svc.get_event(uid, event_id)
|
|
||||||
if event is None:
|
|
||||||
raise ValueError(f"event {event_id} not found")
|
|
||||||
return event.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_event(
|
|
||||||
event_id: int,
|
|
||||||
title: str = "",
|
|
||||||
start_date: str = "",
|
|
||||||
start_time: str = "",
|
|
||||||
duration_minutes: int = -1,
|
|
||||||
location: str = "",
|
|
||||||
description: str = "",
|
|
||||||
) -> dict:
|
|
||||||
"""Update an event. Only explicitly provided fields are changed.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event_id: ID of the event to update.
|
|
||||||
title: New title; omit to leave unchanged.
|
|
||||||
start_date / start_time: BOTH must be set to move the event. Omit either
|
|
||||||
to leave the start_dt unchanged.
|
|
||||||
duration_minutes: -1 leaves unchanged; 0 sets to point event; any
|
|
||||||
positive value sets the duration.
|
|
||||||
location / description: omit to leave unchanged.
|
|
||||||
"""
|
|
||||||
uid = current_user_id()
|
|
||||||
fields: dict = {}
|
|
||||||
if title:
|
|
||||||
fields["title"] = title
|
|
||||||
if location:
|
|
||||||
fields["location"] = location
|
|
||||||
if description:
|
|
||||||
fields["description"] = description
|
|
||||||
if start_date and start_time:
|
|
||||||
fields["start_dt"] = _combine(start_date, start_time)
|
|
||||||
if duration_minutes >= 0:
|
|
||||||
# 0 means point event (NULL); positive sets a real duration.
|
|
||||||
fields["duration_minutes"] = duration_minutes or None
|
|
||||||
event = await events_svc.update_event(uid, event_id, **fields)
|
|
||||||
if event is None:
|
|
||||||
raise ValueError(f"event {event_id} not found")
|
|
||||||
return event.to_dict()
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_event(event_id: int) -> dict:
|
|
||||||
"""Move a calendar event to the trash (recoverable). Restore via restore(batch_id)."""
|
|
||||||
uid = current_user_id()
|
|
||||||
batch = await trash_svc.delete(uid, "event", event_id)
|
|
||||||
if batch is None:
|
|
||||||
raise ValueError(f"event {event_id} not found")
|
|
||||||
return {"deleted_batch_id": batch,
|
|
||||||
"message": f"Event {event_id} moved to trash. Restore with restore('{batch}')."}
|
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
|
||||||
for fn in (
|
|
||||||
list_events,
|
|
||||||
create_event,
|
|
||||||
get_event,
|
|
||||||
update_event,
|
|
||||||
delete_event,
|
|
||||||
):
|
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""search — semantic search across the user's notes and tasks.
|
|
||||||
|
|
||||||
Mirrors the existing fable-mcp contract so Claude's prior usage pattern keeps
|
|
||||||
working. Differences from fable-mcp:
|
|
||||||
- calls services.embeddings.semantic_search_notes directly instead of HTTP
|
|
||||||
- user_id comes from mcp.current_user_id() rather than a global API key
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
|
||||||
from fabledassistant.services.embeddings import semantic_search_notes
|
|
||||||
|
|
||||||
|
|
||||||
async def search(q: str, content_type: str = "all", limit: int = 10) -> dict:
|
|
||||||
"""Semantic search over the user's notes and tasks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
q: search query string.
|
|
||||||
content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only).
|
|
||||||
limit: maximum number of results (1-50).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
|
|
||||||
"total": int}
|
|
||||||
"""
|
|
||||||
uid = current_user_id()
|
|
||||||
limit = max(1, min(limit, 50))
|
|
||||||
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
|
||||||
raw = await semantic_search_notes(
|
|
||||||
uid, q, limit=limit, is_task=is_task,
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"id": note.id,
|
|
||||||
"title": note.title,
|
|
||||||
"body": (note.body or "")[:240],
|
|
||||||
"is_task": bool(note.is_task),
|
|
||||||
"tags": list(note.tags or []),
|
|
||||||
"similarity": float(score),
|
|
||||||
}
|
|
||||||
for score, note in raw
|
|
||||||
],
|
|
||||||
"total": len(raw),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
|
||||||
mcp.tool(name="search")(search)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
|
|
||||||
from fabledassistant.config import Config
|
|
||||||
|
|
||||||
engine = create_async_engine(
|
|
||||||
Config.DATABASE_URL,
|
|
||||||
echo=False,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
pool_recycle=1800,
|
|
||||||
)
|
|
||||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin # noqa: E402, F401
|
|
||||||
|
|
||||||
|
|
||||||
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
|
|
||||||
from fabledassistant.models.setting import Setting # noqa: E402, F401
|
|
||||||
from fabledassistant.models.user import User # noqa: E402, F401
|
|
||||||
from fabledassistant.models.app_log import AppLog # noqa: E402, F401
|
|
||||||
from fabledassistant.models.password_reset import PasswordResetToken # noqa: E402, F401
|
|
||||||
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
|
|
||||||
from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401
|
|
||||||
from fabledassistant.models.project import Project # noqa: E402, F401
|
|
||||||
from fabledassistant.models.event import Event # noqa: E402, F401
|
|
||||||
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.api_key import ApiKey # noqa: E402, F401
|
|
||||||
from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401
|
|
||||||
from fabledassistant.models.rulebook import ( # noqa: E402, F401
|
|
||||||
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
|
|
||||||
)
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
|
||||||
from fabledassistant.models.base import SoftDeleteMixin
|
|
||||||
|
|
||||||
|
|
||||||
class Event(Base, SoftDeleteMixin):
|
|
||||||
__tablename__ = "events"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
user_id: Mapped[int] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id", ondelete="CASCADE")
|
|
||||||
)
|
|
||||||
project_id: Mapped[int | None] = mapped_column(
|
|
||||||
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
# iCal UID for Radicale linkage (unique per user)
|
|
||||||
uid: Mapped[str] = mapped_column(Text)
|
|
||||||
title: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
||||||
# Duration in minutes; NULL = point event with no end specified.
|
|
||||||
# Replaces the prior `end_dt` column (Fable #160 / migration 0043).
|
|
||||||
# The DB has a CHECK constraint that this is NULL or >= 0, so an
|
|
||||||
# event whose end is before its start is structurally inexpressible.
|
|
||||||
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
description: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
location: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
caldav_uid: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
color: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
recurrence: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
reminder_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
reminder_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
|
||||||
)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
default=lambda: datetime.now(timezone.utc),
|
|
||||||
onupdate=lambda: datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def end_dt(self) -> datetime | None:
|
|
||||||
"""Derived end datetime: ``start_dt + duration_minutes``.
|
|
||||||
|
|
||||||
Returns ``None`` for point events (``duration_minutes is None``).
|
|
||||||
Computed at access time rather than stored — a stored end was
|
|
||||||
the source of the "end before start" corruption that motivated
|
|
||||||
this redesign.
|
|
||||||
"""
|
|
||||||
if self.duration_minutes is None:
|
|
||||||
return None
|
|
||||||
return self.start_dt + timedelta(minutes=self.duration_minutes)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
end_dt = self.end_dt
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"user_id": self.user_id,
|
|
||||||
"uid": self.uid,
|
|
||||||
"caldav_uid": self.caldav_uid,
|
|
||||||
"project_id": self.project_id,
|
|
||||||
"title": self.title,
|
|
||||||
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
|
|
||||||
"end_dt": end_dt.isoformat() if end_dt else None,
|
|
||||||
"duration_minutes": self.duration_minutes,
|
|
||||||
"all_day": self.all_day,
|
|
||||||
"description": self.description,
|
|
||||||
"location": self.location,
|
|
||||||
"color": self.color,
|
|
||||||
"recurrence": self.recurrence,
|
|
||||||
"reminder_minutes": self.reminder_minutes,
|
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
"""Calendar events REST API."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from quart import Blueprint, g, jsonify, request
|
|
||||||
|
|
||||||
from fabledassistant.auth import login_required
|
|
||||||
import fabledassistant.services.events as events_svc
|
|
||||||
|
|
||||||
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_dt(value: str) -> datetime:
|
|
||||||
"""Parse ISO 8601 datetime string, ensuring UTC-awareness."""
|
|
||||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
def _get_current_user_id() -> int:
|
|
||||||
return g.user.id
|
|
||||||
|
|
||||||
|
|
||||||
@events_bp.get("")
|
|
||||||
@login_required
|
|
||||||
async def list_events():
|
|
||||||
date_from_str = request.args.get("from")
|
|
||||||
date_to_str = request.args.get("to")
|
|
||||||
if not date_from_str or not date_to_str:
|
|
||||||
return jsonify({"error": "from and to query params are required"}), 400
|
|
||||||
try:
|
|
||||||
date_from = _parse_dt(date_from_str)
|
|
||||||
date_to = _parse_dt(date_to_str)
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "Invalid datetime format"}), 400
|
|
||||||
events = await events_svc.list_events(
|
|
||||||
user_id=_get_current_user_id(),
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
)
|
|
||||||
return jsonify(events)
|
|
||||||
|
|
||||||
|
|
||||||
@events_bp.post("")
|
|
||||||
@login_required
|
|
||||||
async def create_event():
|
|
||||||
data = await request.get_json() or {}
|
|
||||||
if not data.get("title") or not data.get("start_dt"):
|
|
||||||
return jsonify({"error": "title and start_dt are required"}), 400
|
|
||||||
try:
|
|
||||||
start_dt = _parse_dt(data["start_dt"])
|
|
||||||
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "Invalid datetime format"}), 400
|
|
||||||
try:
|
|
||||||
event = await events_svc.create_event(
|
|
||||||
user_id=_get_current_user_id(),
|
|
||||||
title=data["title"],
|
|
||||||
start_dt=start_dt,
|
|
||||||
end_dt=end_dt,
|
|
||||||
duration_minutes=data.get("duration_minutes"),
|
|
||||||
all_day=data.get("all_day", False),
|
|
||||||
description=data.get("description", ""),
|
|
||||||
location=data.get("location", ""),
|
|
||||||
color=data.get("color", ""),
|
|
||||||
recurrence=data.get("recurrence"),
|
|
||||||
project_id=data.get("project_id"),
|
|
||||||
reminder_minutes=data.get("reminder_minutes"),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
return jsonify({"error": str(exc)}), 400
|
|
||||||
return jsonify(event.to_dict()), 201
|
|
||||||
|
|
||||||
|
|
||||||
@events_bp.get("/<int:event_id>")
|
|
||||||
@login_required
|
|
||||||
async def get_event(event_id: int):
|
|
||||||
event = await events_svc.get_event(
|
|
||||||
user_id=_get_current_user_id(),
|
|
||||||
event_id=event_id,
|
|
||||||
)
|
|
||||||
if event is None:
|
|
||||||
return jsonify({"error": "Event not found"}), 404
|
|
||||||
return jsonify(event.to_dict())
|
|
||||||
|
|
||||||
|
|
||||||
@events_bp.patch("/<int:event_id>")
|
|
||||||
@login_required
|
|
||||||
async def update_event(event_id: int):
|
|
||||||
data = await request.get_json() or {}
|
|
||||||
fields: dict = {}
|
|
||||||
for str_field in ("title", "description", "location", "color", "recurrence"):
|
|
||||||
if str_field in data:
|
|
||||||
fields[str_field] = data[str_field]
|
|
||||||
for bool_field in ("all_day",):
|
|
||||||
if bool_field in data:
|
|
||||||
fields[bool_field] = data[bool_field]
|
|
||||||
for int_field in ("project_id", "reminder_minutes", "duration_minutes"):
|
|
||||||
if int_field in data:
|
|
||||||
fields[int_field] = data[int_field]
|
|
||||||
for dt_field in ("start_dt", "end_dt"):
|
|
||||||
if dt_field in data:
|
|
||||||
if data[dt_field] is None:
|
|
||||||
# Explicit null clears the field (e.g. removing end_dt)
|
|
||||||
fields[dt_field] = None
|
|
||||||
elif data[dt_field]:
|
|
||||||
try:
|
|
||||||
fields[dt_field] = _parse_dt(data[dt_field])
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
|
||||||
try:
|
|
||||||
event = await events_svc.update_event(
|
|
||||||
user_id=_get_current_user_id(),
|
|
||||||
event_id=event_id,
|
|
||||||
**fields,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
return jsonify({"error": str(exc)}), 400
|
|
||||||
if event is None:
|
|
||||||
return jsonify({"error": "Event not found"}), 404
|
|
||||||
return jsonify(event.to_dict())
|
|
||||||
|
|
||||||
|
|
||||||
@events_bp.delete("/<int:event_id>")
|
|
||||||
@login_required
|
|
||||||
async def delete_event(event_id: int):
|
|
||||||
from fabledassistant.services.trash import delete as trash_delete
|
|
||||||
batch = await trash_delete(_get_current_user_id(), "event", event_id)
|
|
||||||
if batch is None:
|
|
||||||
return jsonify({"error": "Event not found"}), 404
|
|
||||||
return "", 204
|
|
||||||
|
|
||||||
|
|
||||||
@events_bp.post("/sync")
|
|
||||||
@login_required
|
|
||||||
async def sync_caldav():
|
|
||||||
"""Trigger a CalDAV pull sync for the current user."""
|
|
||||||
from fabledassistant.services.caldav_sync import sync_user_events
|
|
||||||
result = await sync_user_events(user_id=_get_current_user_id())
|
|
||||||
return jsonify(result)
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""User settings + integrations (CalDAV, SearXNG status).
|
|
||||||
|
|
||||||
Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule
|
|
||||||
hooks were removed in Phase 8 alongside the chat/journal subsystems.
|
|
||||||
"""
|
|
||||||
import ipaddress
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
|
||||||
from fabledassistant.config import Config
|
|
||||||
from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
|
|
||||||
from fabledassistant.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_private_url(url: str) -> bool:
|
|
||||||
"""SSRF-blocking helper: returns True for URLs that resolve to private,
|
|
||||||
loopback, or link-local addresses. Inlined here after services/llm.py
|
|
||||||
(the original home) was removed in Phase 8."""
|
|
||||||
try:
|
|
||||||
host = urlparse(url).hostname
|
|
||||||
if not host:
|
|
||||||
return True
|
|
||||||
# Resolve to all addresses; reject if any is private/loopback/link-local.
|
|
||||||
infos = socket.getaddrinfo(host, None)
|
|
||||||
for family, *_rest, sockaddr in infos:
|
|
||||||
ip_str = sockaddr[0]
|
|
||||||
try:
|
|
||||||
ip = ipaddress.ip_address(ip_str)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
# Conservative: if we can't resolve, treat as private (reject).
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route("", methods=["GET"])
|
|
||||||
@login_required
|
|
||||||
async def get_settings_route():
|
|
||||||
uid = get_current_user_id()
|
|
||||||
settings = await get_all_settings(uid)
|
|
||||||
return jsonify(settings)
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route("", methods=["PUT"])
|
|
||||||
@login_required
|
|
||||||
async def update_settings_route():
|
|
||||||
uid = get_current_user_id()
|
|
||||||
data = await request.get_json()
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return jsonify({"error": "Expected a JSON object"}), 400
|
|
||||||
|
|
||||||
to_save = {}
|
|
||||||
for k, v in data.items():
|
|
||||||
str_v = str(v)
|
|
||||||
if not str_v:
|
|
||||||
await delete_setting(uid, k)
|
|
||||||
else:
|
|
||||||
to_save[k] = str_v
|
|
||||||
|
|
||||||
if to_save:
|
|
||||||
await set_settings_batch(uid, to_save)
|
|
||||||
|
|
||||||
settings = await get_all_settings(uid)
|
|
||||||
return jsonify(settings)
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route("/caldav", methods=["GET"])
|
|
||||||
@login_required
|
|
||||||
async def get_caldav():
|
|
||||||
uid = get_current_user_id()
|
|
||||||
config = await get_caldav_config(uid)
|
|
||||||
if config.get("caldav_password"):
|
|
||||||
config["caldav_password"] = "********"
|
|
||||||
return jsonify(config)
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route("/caldav", methods=["PUT"])
|
|
||||||
@login_required
|
|
||||||
async def update_caldav():
|
|
||||||
uid = get_current_user_id()
|
|
||||||
data = await request.get_json()
|
|
||||||
|
|
||||||
# Validate CalDAV URL before saving — block internal/private addresses
|
|
||||||
if "caldav_url" in data:
|
|
||||||
url = str(data.get("caldav_url") or "").strip()
|
|
||||||
if url:
|
|
||||||
parsed_scheme = url.split("://")[0].lower() if "://" in url else ""
|
|
||||||
if parsed_scheme not in ("http", "https"):
|
|
||||||
return jsonify({"error": "CalDAV URL must use http or https"}), 400
|
|
||||||
if _is_private_url(url):
|
|
||||||
return jsonify({"error": "CalDAV URL must not point to an internal or private address"}), 400
|
|
||||||
|
|
||||||
settings_to_save = {}
|
|
||||||
for key in CALDAV_SETTING_KEYS:
|
|
||||||
if key in data:
|
|
||||||
if key == "caldav_password" and data[key] == "********":
|
|
||||||
continue
|
|
||||||
settings_to_save[key] = str(data[key])
|
|
||||||
|
|
||||||
if settings_to_save:
|
|
||||||
await set_settings_batch(uid, settings_to_save)
|
|
||||||
return jsonify({"status": "ok"})
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route("/caldav/test", methods=["POST"])
|
|
||||||
@login_required
|
|
||||||
async def test_caldav():
|
|
||||||
uid = get_current_user_id()
|
|
||||||
result = await test_connection(uid)
|
|
||||||
return jsonify(result)
|
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route("/search", methods=["GET"])
|
|
||||||
@login_required
|
|
||||||
async def test_search():
|
|
||||||
"""Report SearXNG configuration status (used by the Integrations tab)."""
|
|
||||||
if not Config.searxng_enabled():
|
|
||||||
return jsonify({"configured": False, "results": [], "searxng_url": ""})
|
|
||||||
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
|
|
||||||
@@ -1,770 +0,0 @@
|
|||||||
"""CalDAV calendar integration service."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import date as date_type, datetime, timedelta
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import caldav
|
|
||||||
import icalendar
|
|
||||||
|
|
||||||
from fabledassistant.services.settings import get_all_settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "caldav_timezone"]
|
|
||||||
|
|
||||||
# Sentinel: distinguishes "leave the RRULE untouched" from "clear it" (None/"")
|
|
||||||
# in update_event, since None is a meaningful value for recurrence.
|
|
||||||
_RECURRENCE_UNSET = object()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_caldav_config(user_id: int) -> dict[str, str]:
|
|
||||||
"""Return the user's CalDAV config from their settings."""
|
|
||||||
all_settings = await get_all_settings(user_id)
|
|
||||||
return {k: all_settings.get(k, "") for k in CALDAV_SETTING_KEYS}
|
|
||||||
|
|
||||||
|
|
||||||
async def is_caldav_configured(user_id: int) -> bool:
|
|
||||||
"""Check if the user has configured an external CalDAV server."""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
return bool(config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password"))
|
|
||||||
|
|
||||||
|
|
||||||
def _get_calendar(client: caldav.DAVClient, calendar_name: str) -> caldav.Calendar:
|
|
||||||
"""Get a named calendar or the first available one (synchronous)."""
|
|
||||||
principal = client.principal()
|
|
||||||
calendars = principal.calendars()
|
|
||||||
if not calendars:
|
|
||||||
raise ValueError("No calendars found on the CalDAV server.")
|
|
||||||
if calendar_name:
|
|
||||||
for cal in calendars:
|
|
||||||
if cal.name == calendar_name:
|
|
||||||
return cal
|
|
||||||
names = [c.name for c in calendars]
|
|
||||||
raise ValueError(f"Calendar '{calendar_name}' not found. Available: {', '.join(names)}")
|
|
||||||
return calendars[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_all_calendars(client: caldav.DAVClient) -> list[caldav.Calendar]:
|
|
||||||
"""Get all calendars for the user (synchronous)."""
|
|
||||||
principal = client.principal()
|
|
||||||
calendars = principal.calendars()
|
|
||||||
if not calendars:
|
|
||||||
raise ValueError("No calendars found on the CalDAV server.")
|
|
||||||
return calendars
|
|
||||||
|
|
||||||
|
|
||||||
def _make_client(config: dict[str, str]) -> caldav.DAVClient:
|
|
||||||
"""Create a CalDAV client from config dict."""
|
|
||||||
return caldav.DAVClient(
|
|
||||||
url=config["caldav_url"],
|
|
||||||
username=config.get("caldav_username") or None,
|
|
||||||
password=config.get("caldav_password") or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_vevent(component) -> dict | None:
|
|
||||||
"""Extract event data from a VEVENT component."""
|
|
||||||
if component.name != "VEVENT":
|
|
||||||
return None
|
|
||||||
title = str(component.get("SUMMARY", ""))
|
|
||||||
dtstart = component.get("DTSTART")
|
|
||||||
dtend = component.get("DTEND")
|
|
||||||
location = str(component.get("LOCATION", ""))
|
|
||||||
description = str(component.get("DESCRIPTION", ""))
|
|
||||||
uid = str(component.get("UID", ""))
|
|
||||||
start_str = dtstart.dt.isoformat() if dtstart else ""
|
|
||||||
end_str = dtend.dt.isoformat() if dtend else ""
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"uid": uid,
|
|
||||||
"title": title,
|
|
||||||
"start": start_str,
|
|
||||||
"end": end_str,
|
|
||||||
"location": location,
|
|
||||||
"description": description,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract recurrence rule
|
|
||||||
rrule = component.get("RRULE")
|
|
||||||
if rrule:
|
|
||||||
result["recurrence"] = rrule.to_ical().decode("utf-8")
|
|
||||||
|
|
||||||
# Extract alarms
|
|
||||||
alarms = []
|
|
||||||
for sub in component.subcomponents:
|
|
||||||
if sub.name == "VALARM":
|
|
||||||
trigger = sub.get("TRIGGER")
|
|
||||||
if trigger and trigger.dt:
|
|
||||||
minutes = abs(int(trigger.dt.total_seconds() // 60))
|
|
||||||
alarms.append({"minutes_before": minutes})
|
|
||||||
if alarms:
|
|
||||||
result["alarms"] = alarms
|
|
||||||
|
|
||||||
# Extract attendees
|
|
||||||
attendees = component.get("ATTENDEE")
|
|
||||||
if attendees:
|
|
||||||
if not isinstance(attendees, list):
|
|
||||||
attendees = [attendees]
|
|
||||||
result["attendees"] = [str(a).replace("mailto:", "") for a in attendees]
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_vtodo(component) -> dict | None:
|
|
||||||
"""Extract todo data from a VTODO component."""
|
|
||||||
if component.name != "VTODO":
|
|
||||||
return None
|
|
||||||
uid = str(component.get("UID", ""))
|
|
||||||
summary = str(component.get("SUMMARY", ""))
|
|
||||||
description = str(component.get("DESCRIPTION", ""))
|
|
||||||
status = str(component.get("STATUS", ""))
|
|
||||||
due = component.get("DUE")
|
|
||||||
due_str = due.dt.isoformat() if due else ""
|
|
||||||
priority = component.get("PRIORITY")
|
|
||||||
priority_val = int(priority) if priority else None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"uid": uid,
|
|
||||||
"summary": summary,
|
|
||||||
"description": description,
|
|
||||||
"due": due_str,
|
|
||||||
"status": status,
|
|
||||||
"priority": priority_val,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_timezone(dt: datetime, timezone: str | None) -> datetime:
|
|
||||||
"""Apply a timezone to a naive datetime. Returns dt unchanged if already aware."""
|
|
||||||
if dt.tzinfo is not None:
|
|
||||||
return dt
|
|
||||||
if timezone:
|
|
||||||
return dt.replace(tzinfo=ZoneInfo(timezone))
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
def _build_valarm(minutes_before: int) -> icalendar.Alarm:
|
|
||||||
"""Create a DISPLAY alarm component triggered N minutes before the event."""
|
|
||||||
alarm = icalendar.Alarm()
|
|
||||||
alarm.add("action", "DISPLAY")
|
|
||||||
alarm.add("description", "Reminder")
|
|
||||||
alarm.add("trigger", timedelta(minutes=-minutes_before))
|
|
||||||
return alarm
|
|
||||||
|
|
||||||
|
|
||||||
def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None:
|
|
||||||
"""Add mailto: attendees to an iCalendar event."""
|
|
||||||
for email in attendees:
|
|
||||||
attendee = icalendar.vCalAddress(f"mailto:{email}")
|
|
||||||
event.add("attendee", attendee)
|
|
||||||
|
|
||||||
|
|
||||||
def _check_config(config: dict[str, str]) -> None:
|
|
||||||
"""Raise if CalDAV is not configured."""
|
|
||||||
if not config.get("caldav_url"):
|
|
||||||
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to enter your server URL.")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_event(
|
|
||||||
user_id: int,
|
|
||||||
title: str,
|
|
||||||
start: str,
|
|
||||||
end: str | None = None,
|
|
||||||
duration: int | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
location: str | None = None,
|
|
||||||
all_day: bool = False,
|
|
||||||
recurrence: str | None = None,
|
|
||||||
timezone: str | None = None,
|
|
||||||
reminder_minutes: int | None = None,
|
|
||||||
attendees: list[str] | None = None,
|
|
||||||
calendar_name: str | None = None,
|
|
||||||
uid: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Create a calendar event.
|
|
||||||
|
|
||||||
start/end are ISO date (YYYY-MM-DD) or datetime strings.
|
|
||||||
If all_day is True, DTSTART/DTEND use DATE values.
|
|
||||||
recurrence is an iCalendar RRULE string (e.g. "FREQ=YEARLY").
|
|
||||||
"""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
_check_config(config)
|
|
||||||
|
|
||||||
tz = timezone or config.get("caldav_timezone") or None
|
|
||||||
|
|
||||||
cal = icalendar.Calendar()
|
|
||||||
cal.add("prodid", "-//FabledAssistant//EN")
|
|
||||||
cal.add("version", "2.0")
|
|
||||||
|
|
||||||
event = icalendar.Event()
|
|
||||||
if uid:
|
|
||||||
# Remove auto-generated UID if the library added one, then inject ours
|
|
||||||
if "UID" in event:
|
|
||||||
del event["UID"]
|
|
||||||
event.add("uid", uid)
|
|
||||||
event.add("summary", title)
|
|
||||||
|
|
||||||
if all_day:
|
|
||||||
# All-day events use DATE values (no time component)
|
|
||||||
d_start = datetime.fromisoformat(start).date() if "T" in start else date_type.fromisoformat(start)
|
|
||||||
if end:
|
|
||||||
d_end = datetime.fromisoformat(end).date() if "T" in end else date_type.fromisoformat(end)
|
|
||||||
else:
|
|
||||||
d_end = d_start + timedelta(days=1)
|
|
||||||
event.add("dtstart", d_start)
|
|
||||||
event.add("dtend", d_end)
|
|
||||||
result_start = d_start.isoformat()
|
|
||||||
result_end = d_end.isoformat()
|
|
||||||
else:
|
|
||||||
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
|
|
||||||
event.add("dtstart", dt_start)
|
|
||||||
result_start = dt_start.isoformat()
|
|
||||||
if end:
|
|
||||||
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
|
|
||||||
elif duration:
|
|
||||||
dt_end = dt_start + timedelta(minutes=duration)
|
|
||||||
else:
|
|
||||||
dt_end = None
|
|
||||||
if dt_end is not None:
|
|
||||||
event.add("dtend", dt_end)
|
|
||||||
result_end = dt_end.isoformat()
|
|
||||||
else:
|
|
||||||
# Point event (no end, no duration): emit DTSTART only. Fabricating
|
|
||||||
# a 60-min DTEND here would round-trip back on the next pull as
|
|
||||||
# duration_minutes=60, silently lengthening a point event.
|
|
||||||
result_end = None
|
|
||||||
|
|
||||||
if description:
|
|
||||||
event.add("description", description)
|
|
||||||
if location:
|
|
||||||
event.add("location", location)
|
|
||||||
if recurrence:
|
|
||||||
# Parse RRULE string like "FREQ=YEARLY" into a vRecur dict
|
|
||||||
rrule_parts = {}
|
|
||||||
for part in recurrence.split(";"):
|
|
||||||
if "=" in part:
|
|
||||||
key, value = part.split("=", 1)
|
|
||||||
rrule_parts[key.strip().lower()] = value.strip()
|
|
||||||
event.add("rrule", rrule_parts)
|
|
||||||
if reminder_minutes is not None:
|
|
||||||
event.add_component(_build_valarm(reminder_minutes))
|
|
||||||
if attendees:
|
|
||||||
_add_attendees(event, attendees)
|
|
||||||
|
|
||||||
cal.add_component(event)
|
|
||||||
|
|
||||||
ical_str = cal.to_ical().decode("utf-8")
|
|
||||||
|
|
||||||
def _save():
|
|
||||||
client = _make_client(config)
|
|
||||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
|
||||||
calendar = _get_calendar(client, cal_name)
|
|
||||||
calendar.save_event(ical_str)
|
|
||||||
|
|
||||||
await asyncio.to_thread(_save)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"title": title,
|
|
||||||
"start": result_start,
|
|
||||||
"end": result_end,
|
|
||||||
"all_day": all_day,
|
|
||||||
}
|
|
||||||
if recurrence:
|
|
||||||
result["recurrence"] = recurrence
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]:
|
|
||||||
"""List calendar events in a date range. Dates are ISO datetime strings.
|
|
||||||
|
|
||||||
Searches all calendars unless caldav_calendar_name is configured.
|
|
||||||
"""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
_check_config(config)
|
|
||||||
|
|
||||||
dt_from = datetime.fromisoformat(date_from)
|
|
||||||
dt_to = datetime.fromisoformat(date_to)
|
|
||||||
|
|
||||||
def _search():
|
|
||||||
client = _make_client(config)
|
|
||||||
cal_name = config.get("caldav_calendar_name", "")
|
|
||||||
if cal_name:
|
|
||||||
calendars = [_get_calendar(client, cal_name)]
|
|
||||||
else:
|
|
||||||
calendars = _get_all_calendars(client)
|
|
||||||
all_results = []
|
|
||||||
for calendar in calendars:
|
|
||||||
try:
|
|
||||||
all_results.extend(calendar.date_search(dt_from, dt_to))
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to search calendar '%s'", getattr(calendar, 'name', '?'))
|
|
||||||
return all_results
|
|
||||||
|
|
||||||
results = await asyncio.to_thread(_search)
|
|
||||||
|
|
||||||
events = []
|
|
||||||
for result in results:
|
|
||||||
cal = icalendar.Calendar.from_ical(result.data)
|
|
||||||
for component in cal.walk():
|
|
||||||
parsed = _parse_vevent(component)
|
|
||||||
if parsed:
|
|
||||||
events.append(parsed)
|
|
||||||
return events
|
|
||||||
|
|
||||||
|
|
||||||
async def search_events(user_id: int, query: str, days_ahead: int = 90) -> list[dict]:
|
|
||||||
"""Search events by keyword in the next N days."""
|
|
||||||
now = datetime.now()
|
|
||||||
date_from = now.isoformat()
|
|
||||||
date_to = (now + timedelta(days=days_ahead)).isoformat()
|
|
||||||
|
|
||||||
all_events = await list_events(user_id, date_from, date_to)
|
|
||||||
q = query.lower()
|
|
||||||
return [
|
|
||||||
e for e in all_events
|
|
||||||
if q in e["title"].lower() or q in e.get("location", "").lower() or q in e.get("description", "").lower()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def update_event(
|
|
||||||
user_id: int,
|
|
||||||
query: str,
|
|
||||||
title: str | None = None,
|
|
||||||
start: str | None = None,
|
|
||||||
end: str | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
location: str | None = None,
|
|
||||||
timezone: str | None = None,
|
|
||||||
calendar_name: str | None = None,
|
|
||||||
recurrence: str | None | object = _RECURRENCE_UNSET,
|
|
||||||
) -> dict:
|
|
||||||
"""Update a calendar event matching the query.
|
|
||||||
|
|
||||||
``recurrence``: leave at the sentinel to keep the existing RRULE; pass an
|
|
||||||
RRULE string to set it, or None/"" to remove it. The push path passes the
|
|
||||||
local event's recurrence so RRULE edits propagate to the server.
|
|
||||||
"""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
_check_config(config)
|
|
||||||
tz = timezone or config.get("caldav_timezone") or None
|
|
||||||
|
|
||||||
def _do_update():
|
|
||||||
client = _make_client(config)
|
|
||||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
|
||||||
now = datetime.now()
|
|
||||||
if cal_name:
|
|
||||||
calendars = [_get_calendar(client, cal_name)]
|
|
||||||
else:
|
|
||||||
calendars = _get_all_calendars(client)
|
|
||||||
results = []
|
|
||||||
for cal in calendars:
|
|
||||||
try:
|
|
||||||
results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365)))
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?'))
|
|
||||||
|
|
||||||
q = query.lower()
|
|
||||||
matches = []
|
|
||||||
for r in results:
|
|
||||||
cal_obj = icalendar.Calendar.from_ical(r.data)
|
|
||||||
for component in cal_obj.walk():
|
|
||||||
if component.name == "VEVENT":
|
|
||||||
event_title = str(component.get("SUMMARY", ""))
|
|
||||||
if q in event_title.lower():
|
|
||||||
matches.append((r, component))
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
raise ValueError(f"No event found matching '{query}'.")
|
|
||||||
if len(matches) > 3:
|
|
||||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
|
||||||
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
|
|
||||||
|
|
||||||
event_obj, component = matches[0]
|
|
||||||
if title:
|
|
||||||
component["SUMMARY"] = title
|
|
||||||
if start:
|
|
||||||
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
|
|
||||||
del component["DTSTART"]
|
|
||||||
component.add("dtstart", dt_start)
|
|
||||||
if end:
|
|
||||||
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
|
|
||||||
if "DTEND" in component:
|
|
||||||
del component["DTEND"]
|
|
||||||
component.add("dtend", dt_end)
|
|
||||||
if description is not None:
|
|
||||||
if "DESCRIPTION" in component:
|
|
||||||
del component["DESCRIPTION"]
|
|
||||||
component.add("description", description)
|
|
||||||
if location is not None:
|
|
||||||
if "LOCATION" in component:
|
|
||||||
del component["LOCATION"]
|
|
||||||
component.add("location", location)
|
|
||||||
if recurrence is not _RECURRENCE_UNSET:
|
|
||||||
# Authoritatively sync the RRULE to the local event: drop the old
|
|
||||||
# rule, then re-add if a non-empty rule was provided (else clear it).
|
|
||||||
if "RRULE" in component:
|
|
||||||
del component["RRULE"]
|
|
||||||
if recurrence:
|
|
||||||
rrule_parts = {}
|
|
||||||
for part in str(recurrence).split(";"):
|
|
||||||
if "=" in part:
|
|
||||||
key, value = part.split("=", 1)
|
|
||||||
rrule_parts[key.strip().lower()] = value.strip()
|
|
||||||
component.add("rrule", rrule_parts)
|
|
||||||
|
|
||||||
# Rebuild ical data and save
|
|
||||||
cal_data = icalendar.Calendar()
|
|
||||||
cal_data.add("prodid", "-//FabledAssistant//EN")
|
|
||||||
cal_data.add("version", "2.0")
|
|
||||||
cal_data.add_component(component)
|
|
||||||
event_obj.data = cal_data.to_ical().decode("utf-8")
|
|
||||||
event_obj.save()
|
|
||||||
|
|
||||||
return _parse_vevent(component)
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_do_update)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_event(
|
|
||||||
user_id: int,
|
|
||||||
query: str,
|
|
||||||
calendar_name: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Delete a calendar event matching the query."""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
_check_config(config)
|
|
||||||
|
|
||||||
def _do_delete():
|
|
||||||
client = _make_client(config)
|
|
||||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
|
||||||
now = datetime.now()
|
|
||||||
if cal_name:
|
|
||||||
calendars = [_get_calendar(client, cal_name)]
|
|
||||||
else:
|
|
||||||
calendars = _get_all_calendars(client)
|
|
||||||
results = []
|
|
||||||
for cal in calendars:
|
|
||||||
try:
|
|
||||||
results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365)))
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?'))
|
|
||||||
|
|
||||||
q = query.lower()
|
|
||||||
matches = []
|
|
||||||
for r in results:
|
|
||||||
cal_obj = icalendar.Calendar.from_ical(r.data)
|
|
||||||
for component in cal_obj.walk():
|
|
||||||
if component.name == "VEVENT":
|
|
||||||
event_title = str(component.get("SUMMARY", ""))
|
|
||||||
if q in event_title.lower():
|
|
||||||
matches.append((r, component))
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
raise ValueError(f"No event found matching '{query}'.")
|
|
||||||
if len(matches) > 3:
|
|
||||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
|
||||||
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
|
|
||||||
|
|
||||||
event_obj, component = matches[0]
|
|
||||||
parsed = _parse_vevent(component)
|
|
||||||
event_obj.delete()
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_do_delete)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_calendars(user_id: int) -> list[dict]:
|
|
||||||
"""List all calendars for the user."""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
_check_config(config)
|
|
||||||
|
|
||||||
def _list():
|
|
||||||
client = _make_client(config)
|
|
||||||
principal = client.principal()
|
|
||||||
calendars = principal.calendars()
|
|
||||||
return [{"name": c.name, "url": str(c.url)} for c in calendars]
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_list)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_todo(
|
|
||||||
user_id: int,
|
|
||||||
summary: str,
|
|
||||||
due: str | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
priority: int | None = None,
|
|
||||||
reminder_minutes: int | None = None,
|
|
||||||
timezone: str | None = None,
|
|
||||||
calendar_name: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Create a CalDAV todo (VTODO)."""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
_check_config(config)
|
|
||||||
tz = timezone or config.get("caldav_timezone") or None
|
|
||||||
|
|
||||||
def _create():
|
|
||||||
client = _make_client(config)
|
|
||||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
|
||||||
calendar = _get_calendar(client, cal_name)
|
|
||||||
|
|
||||||
kwargs = {"summary": summary}
|
|
||||||
if due:
|
|
||||||
dt_due = datetime.fromisoformat(due)
|
|
||||||
dt_due = _apply_timezone(dt_due, tz)
|
|
||||||
kwargs["due"] = dt_due
|
|
||||||
|
|
||||||
todo = calendar.save_todo(**kwargs)
|
|
||||||
|
|
||||||
# Modify component for extra fields
|
|
||||||
cal_obj = icalendar.Calendar.from_ical(todo.data)
|
|
||||||
modified = False
|
|
||||||
for component in cal_obj.walk():
|
|
||||||
if component.name == "VTODO":
|
|
||||||
if description:
|
|
||||||
component.add("description", description)
|
|
||||||
modified = True
|
|
||||||
if priority is not None:
|
|
||||||
component.add("priority", priority)
|
|
||||||
modified = True
|
|
||||||
if reminder_minutes is not None:
|
|
||||||
component.add_component(_build_valarm(reminder_minutes))
|
|
||||||
modified = True
|
|
||||||
if modified:
|
|
||||||
todo.data = cal_obj.to_ical().decode("utf-8")
|
|
||||||
todo.save()
|
|
||||||
return _parse_vtodo(component)
|
|
||||||
|
|
||||||
return {"summary": summary}
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_create)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_todos(
|
|
||||||
user_id: int,
|
|
||||||
include_completed: bool = False,
|
|
||||||
calendar_name: str | None = None,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""List CalDAV todos."""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
_check_config(config)
|
|
||||||
|
|
||||||
def _list():
|
|
||||||
client = _make_client(config)
|
|
||||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
|
||||||
calendar = _get_calendar(client, cal_name)
|
|
||||||
todos = calendar.todos(include_completed=include_completed)
|
|
||||||
results = []
|
|
||||||
for t in todos:
|
|
||||||
cal_obj = icalendar.Calendar.from_ical(t.data)
|
|
||||||
for component in cal_obj.walk():
|
|
||||||
parsed = _parse_vtodo(component)
|
|
||||||
if parsed:
|
|
||||||
results.append(parsed)
|
|
||||||
return results
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_list)
|
|
||||||
|
|
||||||
|
|
||||||
async def search_todos(
|
|
||||||
user_id: int,
|
|
||||||
query: str,
|
|
||||||
include_completed: bool = False,
|
|
||||||
calendar_name: str | None = None,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Search CalDAV todos by keyword in summary or description."""
|
|
||||||
todos = await list_todos(user_id, include_completed=include_completed, calendar_name=calendar_name)
|
|
||||||
q = query.lower()
|
|
||||||
return [
|
|
||||||
t for t in todos
|
|
||||||
if q in t.get("summary", "").lower() or q in (t.get("description") or "").lower()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def complete_todo(
|
|
||||||
user_id: int,
|
|
||||||
query: str,
|
|
||||||
calendar_name: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Complete a CalDAV todo matching the query."""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
_check_config(config)
|
|
||||||
|
|
||||||
def _complete():
|
|
||||||
client = _make_client(config)
|
|
||||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
|
||||||
calendar = _get_calendar(client, cal_name)
|
|
||||||
todos = calendar.todos(include_completed=False)
|
|
||||||
|
|
||||||
q = query.lower()
|
|
||||||
matches = []
|
|
||||||
for t in todos:
|
|
||||||
cal_obj = icalendar.Calendar.from_ical(t.data)
|
|
||||||
for component in cal_obj.walk():
|
|
||||||
if component.name == "VTODO":
|
|
||||||
s = str(component.get("SUMMARY", ""))
|
|
||||||
if q in s.lower():
|
|
||||||
matches.append((t, component))
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
raise ValueError(f"No todo found matching '{query}'.")
|
|
||||||
if len(matches) > 3:
|
|
||||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
|
||||||
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
|
|
||||||
|
|
||||||
todo_obj, component = matches[0]
|
|
||||||
todo_obj.complete()
|
|
||||||
|
|
||||||
# Re-parse after completing
|
|
||||||
cal_obj = icalendar.Calendar.from_ical(todo_obj.data)
|
|
||||||
for comp in cal_obj.walk():
|
|
||||||
parsed = _parse_vtodo(comp)
|
|
||||||
if parsed:
|
|
||||||
return parsed
|
|
||||||
return {"summary": str(component.get("SUMMARY", "")), "status": "COMPLETED"}
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_complete)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_todo(
|
|
||||||
user_id: int,
|
|
||||||
query: str,
|
|
||||||
summary: str | None = None,
|
|
||||||
due: str | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
priority: int | None = None,
|
|
||||||
timezone: str | None = None,
|
|
||||||
calendar_name: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Update a CalDAV todo matching the query."""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
_check_config(config)
|
|
||||||
tz = timezone or config.get("caldav_timezone") or None
|
|
||||||
|
|
||||||
def _do_update():
|
|
||||||
client = _make_client(config)
|
|
||||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
|
||||||
calendar = _get_calendar(client, cal_name)
|
|
||||||
todos = calendar.todos(include_completed=True)
|
|
||||||
|
|
||||||
q = query.lower()
|
|
||||||
matches = []
|
|
||||||
for t in todos:
|
|
||||||
cal_obj = icalendar.Calendar.from_ical(t.data)
|
|
||||||
for component in cal_obj.walk():
|
|
||||||
if component.name == "VTODO":
|
|
||||||
s = str(component.get("SUMMARY", ""))
|
|
||||||
if q in s.lower():
|
|
||||||
matches.append((t, component))
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
raise ValueError(f"No todo found matching '{query}'.")
|
|
||||||
if len(matches) > 3:
|
|
||||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
|
||||||
raise ValueError(
|
|
||||||
f"Too many matches ({len(matches)}) for '{query}'. "
|
|
||||||
f"Be more specific. Found: {', '.join(titles[:10])}"
|
|
||||||
)
|
|
||||||
|
|
||||||
todo_obj, component = matches[0]
|
|
||||||
|
|
||||||
if summary:
|
|
||||||
component["SUMMARY"] = summary
|
|
||||||
if description is not None:
|
|
||||||
if "DESCRIPTION" in component:
|
|
||||||
del component["DESCRIPTION"]
|
|
||||||
component.add("description", description)
|
|
||||||
if priority is not None:
|
|
||||||
if "PRIORITY" in component:
|
|
||||||
del component["PRIORITY"]
|
|
||||||
component.add("priority", priority)
|
|
||||||
if due:
|
|
||||||
if "DUE" in component:
|
|
||||||
del component["DUE"]
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(due)
|
|
||||||
dt = _apply_timezone(dt, tz)
|
|
||||||
component.add("due", dt)
|
|
||||||
except ValueError:
|
|
||||||
component.add("due", date_type.fromisoformat(due))
|
|
||||||
|
|
||||||
# Rebuild ical data and save
|
|
||||||
cal_data = icalendar.Calendar()
|
|
||||||
cal_data.add("prodid", "-//FabledAssistant//EN")
|
|
||||||
cal_data.add("version", "2.0")
|
|
||||||
cal_data.add_component(component)
|
|
||||||
todo_obj.data = cal_data.to_ical().decode("utf-8")
|
|
||||||
todo_obj.save()
|
|
||||||
|
|
||||||
return _parse_vtodo(component)
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_do_update)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_todo(
|
|
||||||
user_id: int,
|
|
||||||
query: str,
|
|
||||||
calendar_name: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Delete a CalDAV todo matching the query."""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
_check_config(config)
|
|
||||||
|
|
||||||
def _delete():
|
|
||||||
client = _make_client(config)
|
|
||||||
cal_name = calendar_name or config.get("caldav_calendar_name", "")
|
|
||||||
calendar = _get_calendar(client, cal_name)
|
|
||||||
todos = calendar.todos(include_completed=True)
|
|
||||||
|
|
||||||
q = query.lower()
|
|
||||||
matches = []
|
|
||||||
for t in todos:
|
|
||||||
cal_obj = icalendar.Calendar.from_ical(t.data)
|
|
||||||
for component in cal_obj.walk():
|
|
||||||
if component.name == "VTODO":
|
|
||||||
s = str(component.get("SUMMARY", ""))
|
|
||||||
if q in s.lower():
|
|
||||||
matches.append((t, component))
|
|
||||||
|
|
||||||
if not matches:
|
|
||||||
raise ValueError(f"No todo found matching '{query}'.")
|
|
||||||
if len(matches) > 3:
|
|
||||||
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
|
|
||||||
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
|
|
||||||
|
|
||||||
todo_obj, component = matches[0]
|
|
||||||
parsed = _parse_vtodo(component)
|
|
||||||
todo_obj.delete()
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_delete)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_connection(user_id: int) -> dict:
|
|
||||||
"""Test the CalDAV connection and return status."""
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
if not config.get("caldav_url"):
|
|
||||||
return {"success": False, "error": "CalDAV is not configured."}
|
|
||||||
|
|
||||||
def _test():
|
|
||||||
client = _make_client(config)
|
|
||||||
principal = client.principal()
|
|
||||||
calendars = principal.calendars()
|
|
||||||
return [c.name for c in calendars]
|
|
||||||
|
|
||||||
try:
|
|
||||||
calendar_names = await asyncio.to_thread(_test)
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"calendars": calendar_names,
|
|
||||||
"message": f"Connected successfully. Found {len(calendar_names)} calendar(s).",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = str(e)
|
|
||||||
if "401" in error_msg or "403" in error_msg or "Unauthorized" in error_msg:
|
|
||||||
error_msg = "Authentication failed. Check your username and password."
|
|
||||||
elif "404" in error_msg or "Not Found" in error_msg:
|
|
||||||
error_msg = "CalDAV endpoint not found. Check your URL."
|
|
||||||
elif "Connection" in error_msg or "resolve" in error_msg:
|
|
||||||
error_msg = f"Connection failed: {error_msg}"
|
|
||||||
return {"success": False, "error": error_msg}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
"""CalDAV pull sync — imports remote events into the internal event store.
|
|
||||||
|
|
||||||
Runs as a scheduled job (hourly) and is also callable via the API.
|
|
||||||
Only syncs events in a rolling 30-day-past / 180-day-future window.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
|
||||||
|
|
||||||
from fabledassistant.models import async_session
|
|
||||||
from fabledassistant.models.event import Event
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_SYNC_PAST_DAYS = 30
|
|
||||||
_SYNC_FUTURE_DAYS = 180
|
|
||||||
# Wall-clock cap on the blocking CalDAV fetch so a hung/slow server can't
|
|
||||||
# wedge the hourly sweep indefinitely.
|
|
||||||
_SYNC_TIMEOUT_SECONDS = 120
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_dt(val: Any) -> datetime | None:
|
|
||||||
"""Convert a date or datetime from an iCal component to a UTC-aware datetime."""
|
|
||||||
if val is None:
|
|
||||||
return None
|
|
||||||
import datetime as _dt_mod
|
|
||||||
if isinstance(val, _dt_mod.datetime):
|
|
||||||
if val.tzinfo is None:
|
|
||||||
return val.replace(tzinfo=timezone.utc)
|
|
||||||
return val.astimezone(timezone.utc)
|
|
||||||
if isinstance(val, _dt_mod.date):
|
|
||||||
# All-day date: treat as midnight UTC
|
|
||||||
return datetime(val.year, val.month, val.day, tzinfo=timezone.utc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_one_user(config: dict[str, str], user_id: int) -> list[dict]:
|
|
||||||
"""Synchronous CalDAV fetch — runs in a thread executor."""
|
|
||||||
import caldav # noqa: PLC0415
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
range_start = now - timedelta(days=_SYNC_PAST_DAYS)
|
|
||||||
range_end = now + timedelta(days=_SYNC_FUTURE_DAYS)
|
|
||||||
|
|
||||||
client = caldav.DAVClient(
|
|
||||||
url=config["caldav_url"],
|
|
||||||
username=config.get("caldav_username") or None,
|
|
||||||
password=config.get("caldav_password") or None,
|
|
||||||
)
|
|
||||||
principal = client.principal()
|
|
||||||
calendars = principal.calendars()
|
|
||||||
if not calendars:
|
|
||||||
return []
|
|
||||||
|
|
||||||
cal_name = config.get("caldav_calendar_name", "")
|
|
||||||
if cal_name:
|
|
||||||
calendars = [c for c in calendars if c.name == cal_name] or calendars
|
|
||||||
|
|
||||||
events: list[dict] = []
|
|
||||||
for calendar in calendars:
|
|
||||||
try:
|
|
||||||
results = calendar.date_search(start=range_start, end=range_end, expand=False)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("CalDAV date_search failed for calendar %s", getattr(calendar, "name", "?"), exc_info=True)
|
|
||||||
continue
|
|
||||||
for vevent_obj in results:
|
|
||||||
try:
|
|
||||||
ical = vevent_obj.icalendar_instance
|
|
||||||
for component in ical.walk():
|
|
||||||
if component.name != "VEVENT":
|
|
||||||
continue
|
|
||||||
dtstart = component.get("DTSTART")
|
|
||||||
dtend = component.get("DTEND")
|
|
||||||
uid = str(component.get("UID", ""))
|
|
||||||
if not uid:
|
|
||||||
continue
|
|
||||||
start_dt = _parse_dt(dtstart.dt if dtstart else None)
|
|
||||||
end_dt = _parse_dt(dtend.dt if dtend else None)
|
|
||||||
if start_dt is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
import datetime as _dt_mod
|
|
||||||
all_day = dtstart and isinstance(dtstart.dt, _dt_mod.date) and not isinstance(dtstart.dt, _dt_mod.datetime)
|
|
||||||
|
|
||||||
rrule = component.get("RRULE")
|
|
||||||
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
|
|
||||||
|
|
||||||
events.append({
|
|
||||||
"caldav_uid": uid,
|
|
||||||
"title": str(component.get("SUMMARY", "")),
|
|
||||||
"start_dt": start_dt,
|
|
||||||
"end_dt": end_dt,
|
|
||||||
"all_day": bool(all_day),
|
|
||||||
"description": str(component.get("DESCRIPTION", "")),
|
|
||||||
"location": str(component.get("LOCATION", "")),
|
|
||||||
"recurrence": recurrence,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Failed to parse CalDAV event", exc_info=True)
|
|
||||||
|
|
||||||
return events
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_user_events(user_id: int) -> dict:
|
|
||||||
"""Pull CalDAV events for one user and upsert into the DB.
|
|
||||||
|
|
||||||
Returns a summary dict: {created, updated, unchanged}.
|
|
||||||
"""
|
|
||||||
from fabledassistant.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415
|
|
||||||
|
|
||||||
if not await is_caldav_configured(user_id):
|
|
||||||
return {"skipped": True, "reason": "CalDAV not configured"}
|
|
||||||
|
|
||||||
config = await get_caldav_config(user_id)
|
|
||||||
|
|
||||||
started = datetime.now(timezone.utc)
|
|
||||||
range_start = started - timedelta(days=_SYNC_PAST_DAYS)
|
|
||||||
range_end = started + timedelta(days=_SYNC_FUTURE_DAYS)
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
try:
|
|
||||||
remote_events: list[dict] = await asyncio.wait_for(
|
|
||||||
loop.run_in_executor(None, _sync_one_user, config, user_id),
|
|
||||||
timeout=_SYNC_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("CalDAV pull sync timed out for user %d after %ds", user_id, _SYNC_TIMEOUT_SECONDS)
|
|
||||||
return {"error": "CalDAV fetch timed out"}
|
|
||||||
except Exception:
|
|
||||||
logger.warning("CalDAV pull sync failed for user %d", user_id, exc_info=True)
|
|
||||||
return {"error": "CalDAV fetch failed"}
|
|
||||||
|
|
||||||
created = updated = unchanged = skipped = deleted = 0
|
|
||||||
|
|
||||||
async with async_session() as session:
|
|
||||||
for ev in remote_events:
|
|
||||||
caldav_uid = ev["caldav_uid"]
|
|
||||||
# Storage uses duration, not end_dt. Convert here so the
|
|
||||||
# rest of this function can compare/upsert in one shape.
|
|
||||||
ev_start = ev["start_dt"]
|
|
||||||
ev_end = ev["end_dt"]
|
|
||||||
ev_duration = (
|
|
||||||
int((ev_end - ev_start).total_seconds() // 60)
|
|
||||||
if ev_end is not None and ev_start is not None and ev_end > ev_start
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
ev["duration_minutes"] = ev_duration
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event).where(
|
|
||||||
Event.user_id == user_id,
|
|
||||||
Event.caldav_uid == caldav_uid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
existing = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if existing is not None and existing.deleted_at is not None:
|
|
||||||
# The user trashed this event locally. Don't resurrect it by
|
|
||||||
# updating, and don't create a duplicate live copy — leave it
|
|
||||||
# in the trash. (Propagating the delete to the remote server is
|
|
||||||
# tracked separately.)
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if existing is None:
|
|
||||||
# Create new event
|
|
||||||
new_ev = Event(
|
|
||||||
user_id=user_id,
|
|
||||||
uid=str(uuid.uuid4()),
|
|
||||||
caldav_uid=caldav_uid,
|
|
||||||
title=ev["title"],
|
|
||||||
start_dt=ev_start,
|
|
||||||
duration_minutes=ev_duration,
|
|
||||||
all_day=ev["all_day"],
|
|
||||||
description=ev["description"],
|
|
||||||
location=ev["location"],
|
|
||||||
recurrence=ev["recurrence"],
|
|
||||||
)
|
|
||||||
session.add(new_ev)
|
|
||||||
created += 1
|
|
||||||
else:
|
|
||||||
# Update if anything changed
|
|
||||||
changed = False
|
|
||||||
for field in ("title", "start_dt", "duration_minutes", "all_day", "description", "location", "recurrence"):
|
|
||||||
if getattr(existing, field) != ev[field]:
|
|
||||||
setattr(existing, field, ev[field])
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
updated += 1
|
|
||||||
else:
|
|
||||||
unchanged += 1
|
|
||||||
|
|
||||||
# Reconcile deletions: a previously-synced event (has a caldav_uid)
|
|
||||||
# that no longer appears remotely within the synced window is
|
|
||||||
# soft-deleted, so a delete on the remote propagates locally instead
|
|
||||||
# of orphaning forever. Guarded on a non-empty fetch so a spurious
|
|
||||||
# empty result can't wipe every local copy.
|
|
||||||
if remote_events:
|
|
||||||
remote_uids = {e["caldav_uid"] for e in remote_events}
|
|
||||||
orphan_batch = str(uuid.uuid4())
|
|
||||||
orphan_res = await session.execute(
|
|
||||||
update(Event)
|
|
||||||
.where(
|
|
||||||
Event.user_id == user_id,
|
|
||||||
Event.caldav_uid.isnot(None),
|
|
||||||
Event.caldav_uid.notin_(remote_uids),
|
|
||||||
Event.deleted_at.is_(None),
|
|
||||||
Event.start_dt >= range_start,
|
|
||||||
Event.start_dt <= range_end,
|
|
||||||
)
|
|
||||||
.values(deleted_at=datetime.now(timezone.utc), deleted_batch_id=orphan_batch)
|
|
||||||
)
|
|
||||||
deleted = orphan_res.rowcount or 0
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
|
|
||||||
logger.info(
|
|
||||||
"CalDAV sync user %d: %d created, %d updated, %d unchanged, %d skipped (trashed), "
|
|
||||||
"%d deleted (orphaned) in %.1fs",
|
|
||||||
user_id, created, updated, unchanged, skipped, deleted, elapsed,
|
|
||||||
)
|
|
||||||
return {"created": created, "updated": updated, "unchanged": unchanged,
|
|
||||||
"skipped": skipped, "deleted": deleted}
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_all_users() -> None:
|
|
||||||
"""Pull CalDAV events for all users with CalDAV configured."""
|
|
||||||
from sqlalchemy import select as sa_select # noqa: PLC0415
|
|
||||||
|
|
||||||
from fabledassistant.models.user import User # noqa: PLC0415
|
|
||||||
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.execute(sa_select(User.id))
|
|
||||||
user_ids = [row[0] for row in result.all()]
|
|
||||||
|
|
||||||
for user_id in user_ids:
|
|
||||||
try:
|
|
||||||
await sync_user_events(user_id)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("CalDAV sync failed for user %d", user_id, exc_info=True)
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"""Scheduler jobs for background maintenance tasks.
|
|
||||||
|
|
||||||
- Reminder notifications: checks every 5 minutes for due event reminders and
|
|
||||||
delivers them to the in-app notification feed.
|
|
||||||
- CalDAV pull sync: runs every hour for all users with CalDAV configured.
|
|
||||||
- Recurring-task spawn: every 15 minutes, creates the next occurrence of any
|
|
||||||
recurring task whose spawn time has arrived.
|
|
||||||
|
|
||||||
Uses the BackgroundScheduler pattern shared with the other *_scheduler modules.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
|
||||||
from dateutil.rrule import rrulestr
|
|
||||||
from sqlalchemy import and_, or_, select
|
|
||||||
|
|
||||||
from fabledassistant.models import async_session
|
|
||||||
from fabledassistant.models.event import Event
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_scheduler: BackgroundScheduler | None = None
|
|
||||||
_loop: asyncio.AbstractEventLoop | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Reminder job
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _fire_reminders() -> None:
|
|
||||||
"""Fire in-app reminders for events whose reminder time has arrived.
|
|
||||||
|
|
||||||
One-shot events fire once (gated on reminder_sent_at IS NULL). Recurring
|
|
||||||
events fire once PER OCCURRENCE: reminder_sent_at stores the start of the
|
|
||||||
occurrence we last reminded about, so each new occurrence re-arms the
|
|
||||||
reminder instead of the whole series firing only once.
|
|
||||||
"""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
window_end = now + timedelta(minutes=5)
|
|
||||||
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event).where(
|
|
||||||
Event.reminder_minutes.isnot(None),
|
|
||||||
Event.deleted_at.is_(None),
|
|
||||||
or_(
|
|
||||||
# Recurring events are evaluated every sweep against their
|
|
||||||
# next occurrence (the base start_dt is long past).
|
|
||||||
Event.recurrence.isnot(None),
|
|
||||||
# One-shot events: classic gate.
|
|
||||||
and_(Event.reminder_sent_at.is_(None), Event.start_dt > now),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
candidates = list(result.scalars().all())
|
|
||||||
|
|
||||||
# (event_id, occurrence_start) — occurrence_start is also the dedup marker
|
|
||||||
# written to reminder_sent_at, so a given occurrence reminds exactly once.
|
|
||||||
to_notify: list[tuple[int, datetime]] = []
|
|
||||||
for event in candidates:
|
|
||||||
if event.recurrence:
|
|
||||||
try:
|
|
||||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
|
||||||
occ = rule.after(now, inc=True)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to expand RRULE for event %d reminder", event.id, exc_info=True)
|
|
||||||
continue
|
|
||||||
if occ is None:
|
|
||||||
continue
|
|
||||||
reminder_dt = occ - timedelta(minutes=event.reminder_minutes)
|
|
||||||
if reminder_dt <= window_end and event.reminder_sent_at != occ:
|
|
||||||
to_notify.append((event.id, occ))
|
|
||||||
else:
|
|
||||||
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
|
|
||||||
if reminder_dt <= window_end:
|
|
||||||
to_notify.append((event.id, event.start_dt))
|
|
||||||
|
|
||||||
if not to_notify:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Deliver via the in-app notification feed (push was removed in Phase 8).
|
|
||||||
from fabledassistant.services.notifications import create_in_app_notification
|
|
||||||
|
|
||||||
async with async_session() as session:
|
|
||||||
for event_id, occurrence_start in to_notify:
|
|
||||||
ev = (await session.execute(
|
|
||||||
select(Event).where(Event.id == event_id)
|
|
||||||
)).scalar_one_or_none()
|
|
||||||
# Skip if this exact occurrence was already reminded (covers a
|
|
||||||
# concurrent sweep and the one-shot already-sent case).
|
|
||||||
if ev is None or ev.reminder_sent_at == occurrence_start:
|
|
||||||
continue
|
|
||||||
await create_in_app_notification(ev.user_id, "event_reminder", {
|
|
||||||
"event_id": ev.id,
|
|
||||||
"title": ev.title,
|
|
||||||
"start_dt": occurrence_start.isoformat(),
|
|
||||||
"url": "/calendar",
|
|
||||||
})
|
|
||||||
# Stamp the occurrence marker only after the notification is
|
|
||||||
# created, so a delivery failure leaves it eligible to retry.
|
|
||||||
ev.reminder_sent_at = occurrence_start
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_reminders(loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
asyncio.run_coroutine_threadsafe(_fire_reminders(), loop)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CalDAV pull sync job
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _run_caldav_sync() -> None:
|
|
||||||
from fabledassistant.services.caldav_sync import sync_all_users # noqa: PLC0415
|
|
||||||
try:
|
|
||||||
await sync_all_users()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("CalDAV pull sync job failed", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Recurring-task spawn job
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _run_recurrence_spawn() -> None:
|
|
||||||
from fabledassistant.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
|
|
||||||
try:
|
|
||||||
await spawn_recurring_tasks()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Recurring-task spawn job failed", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Lifecycle
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
global _scheduler, _loop
|
|
||||||
if _scheduler is not None:
|
|
||||||
return
|
|
||||||
_loop = loop
|
|
||||||
_scheduler = BackgroundScheduler()
|
|
||||||
|
|
||||||
# Check reminders every 5 minutes
|
|
||||||
_scheduler.add_job(
|
|
||||||
_run_reminders,
|
|
||||||
trigger=IntervalTrigger(minutes=5),
|
|
||||||
args=[loop],
|
|
||||||
id="event_reminders",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# CalDAV pull sync every hour
|
|
||||||
_scheduler.add_job(
|
|
||||||
_run_caldav_sync_threadsafe,
|
|
||||||
trigger=IntervalTrigger(hours=1),
|
|
||||||
args=[loop],
|
|
||||||
id="caldav_pull_sync",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Spawn the next occurrence of due recurring tasks every 15 minutes.
|
|
||||||
# Without this job, recurrence_next_spawn_at is armed on completion but
|
|
||||||
# never drained, so recurring tasks never recur.
|
|
||||||
_scheduler.add_job(
|
|
||||||
_run_recurrence_spawn_threadsafe,
|
|
||||||
trigger=IntervalTrigger(minutes=15),
|
|
||||||
args=[loop],
|
|
||||||
id="recurrence_spawn",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
_scheduler.start()
|
|
||||||
logger.info(
|
|
||||||
"Event scheduler started (reminders every 5m, CalDAV sync every 1h, "
|
|
||||||
"recurring-task spawn every 15m)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def stop_event_scheduler() -> None:
|
|
||||||
global _scheduler
|
|
||||||
if _scheduler is not None:
|
|
||||||
_scheduler.shutdown(wait=False)
|
|
||||||
_scheduler = None
|
|
||||||
logger.info("Event scheduler stopped")
|
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
"""Internal event store service with CalDAV push sync.
|
|
||||||
|
|
||||||
Storage model: an event is anchored at ``start_dt`` and has an optional
|
|
||||||
``duration_minutes``. The end of the event is *derived* via
|
|
||||||
``Event.end_dt`` (a Python property), never stored. Callers may still
|
|
||||||
pass ``end_dt`` on writes for ergonomic compatibility — the service
|
|
||||||
converts to ``duration_minutes`` internally. This rules out the entire
|
|
||||||
"end before start" bug class structurally (Fable #160 / migration
|
|
||||||
0043). Open-ended events use ``duration_minutes = None``.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from dateutil.rrule import rrulestr
|
|
||||||
from sqlalchemy import or_, select
|
|
||||||
|
|
||||||
from fabledassistant.models import async_session
|
|
||||||
from fabledassistant.models.event import Event
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_duration(
|
|
||||||
*,
|
|
||||||
start_dt: datetime,
|
|
||||||
end_dt: datetime | None,
|
|
||||||
duration_minutes: int | None,
|
|
||||||
) -> int | None:
|
|
||||||
"""Reduce (end_dt, duration_minutes) inputs to a single canonical
|
|
||||||
``duration_minutes`` value.
|
|
||||||
|
|
||||||
Resolution order:
|
|
||||||
1. If ``duration_minutes`` is explicit, use it (validate >= 0).
|
|
||||||
If ``end_dt`` is also given, validate the two agree.
|
|
||||||
2. Otherwise, derive from ``end_dt - start_dt``.
|
|
||||||
3. Otherwise None (point event with no end).
|
|
||||||
|
|
||||||
Raises ``ValueError`` for any invalid combination — duration < 0,
|
|
||||||
end_dt < start_dt, or end_dt and duration_minutes inconsistent.
|
|
||||||
"""
|
|
||||||
if duration_minutes is not None:
|
|
||||||
if duration_minutes < 0:
|
|
||||||
raise ValueError(
|
|
||||||
f"duration_minutes must be >= 0, got {duration_minutes}"
|
|
||||||
)
|
|
||||||
if end_dt is not None:
|
|
||||||
expected = int((end_dt - start_dt).total_seconds() // 60)
|
|
||||||
if expected != duration_minutes:
|
|
||||||
raise ValueError(
|
|
||||||
f"end_dt ({end_dt.isoformat()}) implies "
|
|
||||||
f"{expected} minutes but duration_minutes={duration_minutes} "
|
|
||||||
f"was passed; pass only one or make them agree."
|
|
||||||
)
|
|
||||||
return duration_minutes
|
|
||||||
if end_dt is not None:
|
|
||||||
delta_seconds = (end_dt - start_dt).total_seconds()
|
|
||||||
if delta_seconds < 0:
|
|
||||||
raise ValueError(
|
|
||||||
f"end_dt ({end_dt.isoformat()}) must be at or after "
|
|
||||||
f"start_dt ({start_dt.isoformat()}); pass end_dt=None "
|
|
||||||
f"or omit it for point events."
|
|
||||||
)
|
|
||||||
return int(delta_seconds // 60)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _localize_naive(user_id: int, dt: datetime | None) -> datetime | None:
|
|
||||||
"""Anchor a naive datetime in the user's timezone; pass tz-aware through.
|
|
||||||
|
|
||||||
Naive datetimes are the user's local wall-clock time (the MCP create/update
|
|
||||||
tools combine date+time without a zone). Attaching the user's tzinfo lets
|
|
||||||
asyncpg store the correct UTC instant, matching the REST/UI path.
|
|
||||||
"""
|
|
||||||
if dt is not None and dt.tzinfo is None:
|
|
||||||
from fabledassistant.services.tz import get_user_tz # noqa: PLC0415
|
|
||||||
return dt.replace(tzinfo=await get_user_tz(user_id))
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
async def create_event(
|
|
||||||
user_id: int,
|
|
||||||
title: str,
|
|
||||||
start_dt: datetime,
|
|
||||||
end_dt: datetime | None = None,
|
|
||||||
duration_minutes: int | None = None,
|
|
||||||
all_day: bool = False,
|
|
||||||
description: str = "",
|
|
||||||
location: str = "",
|
|
||||||
color: str = "",
|
|
||||||
recurrence: str | None = None,
|
|
||||||
project_id: int | None = None,
|
|
||||||
reminder_minutes: int | None = None,
|
|
||||||
# ``duration`` is a legacy alias kept for the calendar tool layer
|
|
||||||
# and CalDAV pass-through callers; promotes to duration_minutes
|
|
||||||
# when duration_minutes isn't otherwise specified.
|
|
||||||
duration: int | None = None,
|
|
||||||
attendees: list[str] | None = None,
|
|
||||||
calendar_name: str | None = None,
|
|
||||||
) -> Event:
|
|
||||||
"""Create an event in the DB, then fire a CalDAV push task.
|
|
||||||
|
|
||||||
Either ``end_dt`` or ``duration_minutes`` may be supplied; the
|
|
||||||
service converts to ``duration_minutes`` internally. Raises
|
|
||||||
``ValueError`` on invalid combinations (negative duration, end
|
|
||||||
before start, end/duration disagreement).
|
|
||||||
"""
|
|
||||||
if duration is not None and duration_minutes is None:
|
|
||||||
duration_minutes = duration
|
|
||||||
# Canonical localization point: a naive datetime (e.g. from the MCP tool's
|
|
||||||
# date+time split) is the user's wall-clock time, so anchor it in their
|
|
||||||
# timezone before storage. tz-aware inputs (REST, CalDAV pass-through) are
|
|
||||||
# left untouched. Without this, MCP-created events landed at the same
|
|
||||||
# wall-clock numerals in UTC and drifted from UI-created ones by the offset.
|
|
||||||
start_dt = await _localize_naive(user_id, start_dt)
|
|
||||||
end_dt = await _localize_naive(user_id, end_dt)
|
|
||||||
duration_minutes = _normalize_duration(
|
|
||||||
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
|
|
||||||
)
|
|
||||||
uid = str(uuid.uuid4())
|
|
||||||
async with async_session() as session:
|
|
||||||
event = Event(
|
|
||||||
user_id=user_id,
|
|
||||||
uid=uid,
|
|
||||||
title=title,
|
|
||||||
start_dt=start_dt,
|
|
||||||
duration_minutes=duration_minutes,
|
|
||||||
all_day=all_day,
|
|
||||||
description=description,
|
|
||||||
location=location,
|
|
||||||
color=color,
|
|
||||||
recurrence=recurrence,
|
|
||||||
project_id=project_id,
|
|
||||||
reminder_minutes=reminder_minutes,
|
|
||||||
)
|
|
||||||
session.add(event)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(event)
|
|
||||||
|
|
||||||
extra_fields = {
|
|
||||||
"duration": duration_minutes,
|
|
||||||
"reminder_minutes": reminder_minutes,
|
|
||||||
"attendees": attendees,
|
|
||||||
"calendar_name": calendar_name,
|
|
||||||
}
|
|
||||||
asyncio.create_task(_push_create(event, user_id, extra_fields))
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
async def get_event(user_id: int, event_id: int) -> Event | None:
|
|
||||||
"""Return event owned by user_id, or None."""
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event).where(
|
|
||||||
Event.id == event_id, Event.user_id == user_id, Event.deleted_at.is_(None)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_events(
|
|
||||||
user_id: int,
|
|
||||||
date_from: datetime,
|
|
||||||
date_to: datetime,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""List events for user_id that overlap [date_from, date_to].
|
|
||||||
|
|
||||||
Recurring events (with an RRULE recurrence string) are expanded into
|
|
||||||
individual occurrences within the range. Non-recurring events are
|
|
||||||
returned as-is. All results are sorted by start time and returned as
|
|
||||||
dicts (same shape as ``Event.to_dict()``).
|
|
||||||
|
|
||||||
Filtering strategy: a coarse SQL prefilter (events that start on or
|
|
||||||
before ``date_to``), then refine in Python using the event's derived
|
|
||||||
end (``start_dt + duration_minutes``). Doing the end-of-event math
|
|
||||||
in SQL would require Postgres-specific interval arithmetic; the
|
|
||||||
Python-side refinement is a few row-loops over a small per-user
|
|
||||||
result set, which is fine for personal-scale data and avoids
|
|
||||||
coupling the query to a specific dialect.
|
|
||||||
"""
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event)
|
|
||||||
.where(
|
|
||||||
Event.user_id == user_id,
|
|
||||||
Event.deleted_at.is_(None),
|
|
||||||
or_(
|
|
||||||
Event.recurrence.isnot(None),
|
|
||||||
Event.start_dt <= date_to,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.order_by(Event.start_dt)
|
|
||||||
)
|
|
||||||
events = list(result.scalars().all())
|
|
||||||
|
|
||||||
items: list[dict] = []
|
|
||||||
for event in events:
|
|
||||||
if event.recurrence:
|
|
||||||
duration = (
|
|
||||||
timedelta(minutes=event.duration_minutes)
|
|
||||||
if event.duration_minutes is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
|
||||||
occurrences = rule.between(date_from, date_to, inc=True)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
|
||||||
# Fall back to canonical event row; still apply the
|
|
||||||
# window check so a far-future canonical row doesn't
|
|
||||||
# leak into today's list.
|
|
||||||
if date_from <= event.start_dt <= date_to:
|
|
||||||
items.append(event.to_dict())
|
|
||||||
continue
|
|
||||||
|
|
||||||
base = event.to_dict()
|
|
||||||
for occ in occurrences:
|
|
||||||
if occ.tzinfo is None:
|
|
||||||
occ = occ.replace(tzinfo=timezone.utc)
|
|
||||||
occurrence_dict = dict(base)
|
|
||||||
occurrence_dict["start_dt"] = occ.isoformat()
|
|
||||||
if duration is not None:
|
|
||||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
|
||||||
items.append(occurrence_dict)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Non-recurring: refine the coarse prefilter in Python using the
|
|
||||||
# derived end_dt. A point event (duration None) is included when
|
|
||||||
# its start is at or after date_from. A timed event is included
|
|
||||||
# when its end is at or after date_from.
|
|
||||||
derived_end = event.end_dt
|
|
||||||
if derived_end is None:
|
|
||||||
if event.start_dt >= date_from:
|
|
||||||
items.append(event.to_dict())
|
|
||||||
else:
|
|
||||||
if derived_end >= date_from:
|
|
||||||
items.append(event.to_dict())
|
|
||||||
|
|
||||||
items.sort(key=lambda x: x["start_dt"])
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
async def search_events(
|
|
||||||
user_id: int,
|
|
||||||
query: str,
|
|
||||||
days_ahead: int = 90,
|
|
||||||
include_past: bool = False,
|
|
||||||
) -> list[Event]:
|
|
||||||
"""Search events by keyword in title, description, or location."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
q = f"%{query}%"
|
|
||||||
async with async_session() as session:
|
|
||||||
where = [
|
|
||||||
Event.user_id == user_id,
|
|
||||||
Event.deleted_at.is_(None),
|
|
||||||
or_(
|
|
||||||
Event.title.ilike(q),
|
|
||||||
Event.description.ilike(q),
|
|
||||||
Event.location.ilike(q),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
if not include_past:
|
|
||||||
date_to = now + timedelta(days=days_ahead)
|
|
||||||
where.extend([Event.start_dt >= now, Event.start_dt <= date_to])
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event).where(*where).order_by(Event.start_dt)
|
|
||||||
)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
|
||||||
"""Partial update. Returns updated event or None if not found.
|
|
||||||
|
|
||||||
Accepts ``end_dt`` or ``duration_minutes`` (or both, validated for
|
|
||||||
agreement). The service converts to ``duration_minutes`` before
|
|
||||||
persisting; ``end_dt`` is never stored. Raises ``ValueError`` for
|
|
||||||
invalid combinations against the post-update state.
|
|
||||||
"""
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event).where(
|
|
||||||
Event.id == event_id, Event.user_id == user_id,
|
|
||||||
Event.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
event = result.scalar_one_or_none()
|
|
||||||
if event is None:
|
|
||||||
return None
|
|
||||||
old_title = event.title # capture before mutation for CalDAV lookup
|
|
||||||
|
|
||||||
# Localize a naive start_dt patch to the user's timezone (same canonical
|
|
||||||
# rule as create_event) before it's used or persisted.
|
|
||||||
if fields.get("start_dt") is not None:
|
|
||||||
fields["start_dt"] = await _localize_naive(user_id, fields["start_dt"])
|
|
||||||
|
|
||||||
# Resolve any end_dt/duration_minutes inputs against the
|
|
||||||
# post-update start_dt. If neither is in the patch, leave the
|
|
||||||
# existing duration_minutes alone.
|
|
||||||
post_update_start = (
|
|
||||||
fields["start_dt"]
|
|
||||||
if fields.get("start_dt") is not None
|
|
||||||
else event.start_dt
|
|
||||||
)
|
|
||||||
if "end_dt" in fields or "duration_minutes" in fields:
|
|
||||||
new_end = fields.pop("end_dt", None)
|
|
||||||
new_duration = fields.pop("duration_minutes", None)
|
|
||||||
# If end_dt is in the patch but explicitly None, that's a
|
|
||||||
# clear → duration_minutes = None. Same shape duration_minutes=None.
|
|
||||||
if new_end is None and new_duration is None:
|
|
||||||
fields["duration_minutes"] = None
|
|
||||||
else:
|
|
||||||
fields["duration_minutes"] = _normalize_duration(
|
|
||||||
start_dt=post_update_start,
|
|
||||||
end_dt=new_end,
|
|
||||||
duration_minutes=new_duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed = {
|
|
||||||
"title", "start_dt", "duration_minutes", "all_day",
|
|
||||||
"description", "location", "color", "recurrence",
|
|
||||||
"project_id", "reminder_minutes",
|
|
||||||
}
|
|
||||||
# Nullable fields callers can explicitly clear by passing None
|
|
||||||
nullable = {
|
|
||||||
"duration_minutes", "recurrence", "project_id",
|
|
||||||
"reminder_minutes",
|
|
||||||
}
|
|
||||||
for key, value in fields.items():
|
|
||||||
if key in allowed and (value is not None or key in nullable):
|
|
||||||
setattr(event, key, value)
|
|
||||||
# Re-arm the reminder when the timing changes, so an event moved to a
|
|
||||||
# new (future) time — or given a new lead time — fires again instead of
|
|
||||||
# being permanently suppressed by a stale reminder_sent_at.
|
|
||||||
if "start_dt" in fields or "reminder_minutes" in fields:
|
|
||||||
event.reminder_sent_at = None
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(event)
|
|
||||||
|
|
||||||
asyncio.create_task(_push_update(event, user_id, old_title=old_title))
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_event(user_id: int, event_id: int) -> None:
|
|
||||||
"""Delete event. Fires CalDAV delete push if caldav_uid is set."""
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
|
||||||
)
|
|
||||||
event = result.scalar_one_or_none()
|
|
||||||
if event is None:
|
|
||||||
return
|
|
||||||
caldav_uid = event.caldav_uid
|
|
||||||
event_title = event.title # needed to find the event on CalDAV by title
|
|
||||||
await session.delete(event)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
if caldav_uid:
|
|
||||||
asyncio.create_task(_push_delete(caldav_uid, event_title, user_id))
|
|
||||||
|
|
||||||
|
|
||||||
async def find_events_by_query(user_id: int, query: str) -> list[Event]:
|
|
||||||
"""ILIKE search on title — used by AI update/delete tools.
|
|
||||||
|
|
||||||
Returns upcoming events first (start_dt >= now), falling back to
|
|
||||||
past events so the AI operates on the most relevant match.
|
|
||||||
"""
|
|
||||||
q = f"%{query}%"
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
async with async_session() as session:
|
|
||||||
# Prefer events at or after now; fall back to past events
|
|
||||||
upcoming = (await session.execute(
|
|
||||||
select(Event).where(
|
|
||||||
Event.user_id == user_id,
|
|
||||||
Event.deleted_at.is_(None),
|
|
||||||
Event.title.ilike(q),
|
|
||||||
Event.start_dt >= now,
|
|
||||||
).order_by(Event.start_dt)
|
|
||||||
)).scalars().all()
|
|
||||||
if upcoming:
|
|
||||||
return list(upcoming)
|
|
||||||
past = (await session.execute(
|
|
||||||
select(Event).where(
|
|
||||||
Event.user_id == user_id,
|
|
||||||
Event.deleted_at.is_(None),
|
|
||||||
Event.title.ilike(q),
|
|
||||||
Event.start_dt < now,
|
|
||||||
).order_by(Event.start_dt.desc())
|
|
||||||
)).scalars().all()
|
|
||||||
return list(past)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CalDAV push helpers (fire-and-forget)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _push_create(event: Event, user_id: int, extra: dict) -> None:
|
|
||||||
try:
|
|
||||||
from fabledassistant.services.caldav import (
|
|
||||||
create_event as caldav_create,
|
|
||||||
is_caldav_configured,
|
|
||||||
)
|
|
||||||
if not await is_caldav_configured(user_id):
|
|
||||||
return
|
|
||||||
derived_end = event.end_dt # property: start + duration_minutes
|
|
||||||
await caldav_create(
|
|
||||||
user_id=user_id,
|
|
||||||
title=event.title,
|
|
||||||
start=event.start_dt.isoformat(),
|
|
||||||
end=derived_end.isoformat() if derived_end else None,
|
|
||||||
description=event.description or None,
|
|
||||||
location=event.location or None,
|
|
||||||
all_day=event.all_day,
|
|
||||||
recurrence=event.recurrence,
|
|
||||||
uid=event.uid,
|
|
||||||
duration=extra.get("duration"),
|
|
||||||
reminder_minutes=extra.get("reminder_minutes"),
|
|
||||||
attendees=extra.get("attendees"),
|
|
||||||
calendar_name=extra.get("calendar_name"),
|
|
||||||
)
|
|
||||||
# Mark as synced
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event).where(Event.id == event.id)
|
|
||||||
)
|
|
||||||
ev = result.scalar_one_or_none()
|
|
||||||
if ev:
|
|
||||||
ev.caldav_uid = event.uid
|
|
||||||
await session.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("CalDAV push (create) failed for event %d", event.id, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
|
|
||||||
"""Push an update to CalDAV. Uses old_title to locate the event by its pre-rename SUMMARY."""
|
|
||||||
if not event.caldav_uid:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
from fabledassistant.services.caldav import (
|
|
||||||
update_event as caldav_update,
|
|
||||||
is_caldav_configured,
|
|
||||||
)
|
|
||||||
if not await is_caldav_configured(user_id):
|
|
||||||
return
|
|
||||||
# Use old_title so CalDAV can find the event even if the title was changed
|
|
||||||
query_title = old_title or event.title
|
|
||||||
derived_end = event.end_dt
|
|
||||||
await caldav_update(
|
|
||||||
user_id=user_id,
|
|
||||||
query=query_title,
|
|
||||||
title=event.title,
|
|
||||||
start=event.start_dt.isoformat(),
|
|
||||||
end=derived_end.isoformat() if derived_end else None,
|
|
||||||
description=event.description or None,
|
|
||||||
location=event.location or None,
|
|
||||||
# Propagate the (possibly cleared) RRULE so a local recurrence edit
|
|
||||||
# isn't overwritten by the stale remote rule on the next pull.
|
|
||||||
recurrence=event.recurrence,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def _push_delete(caldav_uid: str, event_title: str, user_id: int) -> None:
|
|
||||||
"""Push a delete to CalDAV. Uses event_title to locate the event by SUMMARY."""
|
|
||||||
try:
|
|
||||||
from fabledassistant.services.caldav import (
|
|
||||||
delete_event as caldav_delete,
|
|
||||||
is_caldav_configured,
|
|
||||||
)
|
|
||||||
if not await is_caldav_configured(user_id):
|
|
||||||
return
|
|
||||||
await caldav_delete(user_id=user_id, query=event_title)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("CalDAV push (delete) failed for uid %s", caldav_uid, exc_info=True)
|
|
||||||
@@ -5,30 +5,31 @@ from pathlib import Path
|
|||||||
|
|
||||||
from quart import Quart, g, jsonify, make_response, request, send_from_directory
|
from quart import Quart, g, jsonify, make_response, request, send_from_directory
|
||||||
|
|
||||||
from fabledassistant.config import Config
|
from scribe.config import Config
|
||||||
from fabledassistant.routes.admin import admin_bp
|
from scribe.routes.admin import admin_bp
|
||||||
from fabledassistant.routes.api import api
|
from scribe.routes.api import api
|
||||||
from fabledassistant.routes.auth import auth_bp
|
from scribe.routes.auth import auth_bp
|
||||||
from fabledassistant.routes.export import export_bp
|
from scribe.routes.export import export_bp
|
||||||
from fabledassistant.routes.notes import notes_bp
|
from scribe.routes.notes import notes_bp
|
||||||
from fabledassistant.routes.milestones import milestones_bp
|
from scribe.routes.milestones import milestones_bp
|
||||||
from fabledassistant.routes.task_logs import task_logs_bp
|
from scribe.routes.task_logs import task_logs_bp
|
||||||
from fabledassistant.routes.projects import projects_bp
|
from scribe.routes.projects import projects_bp
|
||||||
from fabledassistant.routes.settings import settings_bp
|
from scribe.routes.settings import settings_bp
|
||||||
from fabledassistant.routes.tasks import tasks_bp
|
from scribe.routes.tasks import tasks_bp
|
||||||
from fabledassistant.routes.groups import groups_bp
|
from scribe.routes.groups import groups_bp
|
||||||
from fabledassistant.routes.shares import shares_bp
|
from scribe.routes.shares import shares_bp
|
||||||
from fabledassistant.routes.in_app_notifications import notifications_bp
|
from scribe.routes.in_app_notifications import notifications_bp
|
||||||
from fabledassistant.routes.users import users_bp
|
from scribe.routes.users import users_bp
|
||||||
from fabledassistant.routes.api_keys import api_keys_bp
|
from scribe.routes.api_keys import api_keys_bp
|
||||||
from fabledassistant.routes.events import events_bp
|
from scribe.routes.search import search_bp
|
||||||
from fabledassistant.routes.search import search_bp
|
from scribe.routes.profile import profile_bp
|
||||||
from fabledassistant.routes.profile import profile_bp
|
from scribe.routes.knowledge import knowledge_bp
|
||||||
from fabledassistant.routes.knowledge import knowledge_bp
|
from scribe.routes.rulebooks import rulebooks_bp
|
||||||
from fabledassistant.routes.rulebooks import rulebooks_bp
|
from scribe.routes.plugin import plugin_bp
|
||||||
from fabledassistant.routes.trash import trash_bp
|
from scribe.routes.trash import trash_bp
|
||||||
from fabledassistant.routes.dashboard import dashboard_bp
|
from scribe.routes.dashboard import dashboard_bp
|
||||||
from fabledassistant.mcp import mount_mcp
|
from scribe.routes.systems import systems_bp
|
||||||
|
from scribe.mcp import mount_mcp
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -82,13 +83,14 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(notifications_bp)
|
app.register_blueprint(notifications_bp)
|
||||||
app.register_blueprint(users_bp)
|
app.register_blueprint(users_bp)
|
||||||
app.register_blueprint(api_keys_bp)
|
app.register_blueprint(api_keys_bp)
|
||||||
app.register_blueprint(events_bp)
|
|
||||||
app.register_blueprint(search_bp)
|
app.register_blueprint(search_bp)
|
||||||
app.register_blueprint(profile_bp)
|
app.register_blueprint(profile_bp)
|
||||||
app.register_blueprint(knowledge_bp)
|
app.register_blueprint(knowledge_bp)
|
||||||
app.register_blueprint(rulebooks_bp)
|
app.register_blueprint(rulebooks_bp)
|
||||||
|
app.register_blueprint(plugin_bp)
|
||||||
app.register_blueprint(trash_bp)
|
app.register_blueprint(trash_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
|
app.register_blueprint(systems_bp)
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def before_request():
|
async def before_request():
|
||||||
@@ -115,7 +117,7 @@ def create_app() -> Quart:
|
|||||||
# Log usage for API requests (skip logs endpoint to avoid recursion)
|
# Log usage for API requests (skip logs endpoint to avoid recursion)
|
||||||
if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
|
if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
|
||||||
try:
|
try:
|
||||||
from fabledassistant.services.logging import log_usage
|
from scribe.services.logging import log_usage
|
||||||
|
|
||||||
user = getattr(g, "user", None)
|
user = getattr(g, "user", None)
|
||||||
await log_usage(
|
await log_usage(
|
||||||
@@ -150,10 +152,10 @@ def create_app() -> Quart:
|
|||||||
async def startup():
|
async def startup():
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from fabledassistant.services.auth import start_auth_token_retention_loop
|
from scribe.services.auth import start_auth_token_retention_loop
|
||||||
from fabledassistant.services.embeddings import backfill_note_embeddings
|
from scribe.services.embeddings import backfill_note_embeddings
|
||||||
from fabledassistant.services.logging import start_log_retention_loop
|
from scribe.services.logging import start_log_retention_loop
|
||||||
from fabledassistant.services.notifications import start_notification_loop
|
from scribe.services.notifications import start_notification_loop
|
||||||
|
|
||||||
start_log_retention_loop()
|
start_log_retention_loop()
|
||||||
start_notification_loop()
|
start_notification_loop()
|
||||||
@@ -169,37 +171,50 @@ def create_app() -> Quart:
|
|||||||
|
|
||||||
asyncio.create_task(_delayed_backfill())
|
asyncio.create_task(_delayed_backfill())
|
||||||
|
|
||||||
# Event scheduler (reminders + CalDAV pull sync)
|
# Recurrence scheduler (recurring-task spawn every 15m)
|
||||||
from fabledassistant.services.event_scheduler import start_event_scheduler
|
from scribe.services.recurrence_scheduler import start_recurrence_scheduler
|
||||||
start_event_scheduler(asyncio.get_running_loop())
|
start_recurrence_scheduler(asyncio.get_running_loop())
|
||||||
|
|
||||||
# Version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
|
# Version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
|
||||||
from fabledassistant.services.version_pinning_scheduler import (
|
from scribe.services.version_pinning_scheduler import (
|
||||||
start_version_pinning_scheduler,
|
start_version_pinning_scheduler,
|
||||||
)
|
)
|
||||||
start_version_pinning_scheduler(asyncio.get_running_loop())
|
start_version_pinning_scheduler(asyncio.get_running_loop())
|
||||||
|
|
||||||
# Trash retention scheduler (daily expired-trash purge at 03:30 UTC)
|
# Trash retention scheduler (daily expired-trash purge at 03:30 UTC)
|
||||||
from fabledassistant.services.trash_scheduler import start_trash_scheduler
|
from scribe.services.trash_scheduler import start_trash_scheduler
|
||||||
start_trash_scheduler(asyncio.get_running_loop())
|
start_trash_scheduler(asyncio.get_running_loop())
|
||||||
|
|
||||||
|
# DB maintenance scheduler (daily targeted VACUUM ANALYZE, default 04:00 UTC)
|
||||||
|
from scribe.services.db_maintenance_scheduler import (
|
||||||
|
get_maintenance_hour,
|
||||||
|
start_db_maintenance_scheduler,
|
||||||
|
)
|
||||||
|
start_db_maintenance_scheduler(
|
||||||
|
asyncio.get_running_loop(), await get_maintenance_hour()
|
||||||
|
)
|
||||||
|
|
||||||
# Diagnostic instrumentation — heartbeat, signal handlers, asyncio
|
# Diagnostic instrumentation — heartbeat, signal handlers, asyncio
|
||||||
# exception hook. Cheap (~1 log line/min), high diagnostic value when
|
# exception hook. Cheap (~1 log line/min), high diagnostic value when
|
||||||
# the app crashes mysteriously. See services/diagnostics.py.
|
# the app crashes mysteriously. See services/diagnostics.py.
|
||||||
from fabledassistant.services.diagnostics import start_diagnostics
|
from scribe.services.diagnostics import start_diagnostics
|
||||||
start_diagnostics(asyncio.get_running_loop())
|
start_diagnostics(asyncio.get_running_loop())
|
||||||
|
|
||||||
@app.after_serving
|
@app.after_serving
|
||||||
async def shutdown():
|
async def shutdown():
|
||||||
from fabledassistant.services.event_scheduler import stop_event_scheduler
|
from scribe.services.recurrence_scheduler import stop_recurrence_scheduler
|
||||||
stop_event_scheduler()
|
stop_recurrence_scheduler()
|
||||||
from fabledassistant.services.version_pinning_scheduler import (
|
from scribe.services.version_pinning_scheduler import (
|
||||||
stop_version_pinning_scheduler,
|
stop_version_pinning_scheduler,
|
||||||
)
|
)
|
||||||
stop_version_pinning_scheduler()
|
stop_version_pinning_scheduler()
|
||||||
from fabledassistant.services.trash_scheduler import stop_trash_scheduler
|
from scribe.services.trash_scheduler import stop_trash_scheduler
|
||||||
stop_trash_scheduler()
|
stop_trash_scheduler()
|
||||||
from fabledassistant.services.diagnostics import stop_diagnostics
|
from scribe.services.db_maintenance_scheduler import (
|
||||||
|
stop_db_maintenance_scheduler,
|
||||||
|
)
|
||||||
|
stop_db_maintenance_scheduler()
|
||||||
|
from scribe.services.diagnostics import stop_diagnostics
|
||||||
stop_diagnostics()
|
stop_diagnostics()
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
@@ -249,7 +264,7 @@ def create_app() -> Quart:
|
|||||||
logger.exception("Internal server error on %s %s", request.method, request.path)
|
logger.exception("Internal server error on %s %s", request.method, request.path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fabledassistant.services.logging import log_error
|
from scribe.services.logging import log_error
|
||||||
|
|
||||||
user = getattr(g, "user", None)
|
user = getattr(g, "user", None)
|
||||||
await log_error(
|
await log_error(
|
||||||
@@ -2,8 +2,8 @@ import functools
|
|||||||
|
|
||||||
from quart import g, jsonify, request, session
|
from quart import g, jsonify, request, session
|
||||||
|
|
||||||
from fabledassistant.services.auth import get_user_by_id
|
from scribe.services.auth import get_user_by_id
|
||||||
from fabledassistant.services.api_keys import lookup_key
|
from scribe.services.api_keys import lookup_key
|
||||||
|
|
||||||
|
|
||||||
def _check_auth(f, required_role: str | None = None):
|
def _check_auth(f, required_role: str | None = None):
|
||||||
@@ -20,7 +20,7 @@ class Config:
|
|||||||
DATABASE_URL: str = _read_secret(
|
DATABASE_URL: str = _read_secret(
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"DATABASE_URL_FILE",
|
"DATABASE_URL_FILE",
|
||||||
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
|
"postgresql+asyncpg://scribe:scribe@localhost:5432/scribe",
|
||||||
)
|
)
|
||||||
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
|
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
|
||||||
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
|
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
|
||||||
@@ -38,6 +38,16 @@ class Config:
|
|||||||
BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/")
|
BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/")
|
||||||
TRUST_PROXY_HEADERS: bool = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ("1", "true", "yes")
|
TRUST_PROXY_HEADERS: bool = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
# Git URL of the repo that ships this Scribe plugin. There is one correct
|
||||||
|
# value per deployment (the repo serving the plugin), so it's a config
|
||||||
|
# default — not something each user types. Surfaced in Settings → MCP Access
|
||||||
|
# as the default install-command marketplace; an admin can still override it
|
||||||
|
# per-instance via the Plugin marketplace setting.
|
||||||
|
PLUGIN_MARKETPLACE_URL: str = os.environ.get(
|
||||||
|
"PLUGIN_MARKETPLACE_URL",
|
||||||
|
"https://git.fabledsword.com/bvandeusen/FabledScribe.git",
|
||||||
|
)
|
||||||
|
|
||||||
# OIDC / OAuth2 SSO (e.g. Authentik)
|
# OIDC / OAuth2 SSO (e.g. Authentik)
|
||||||
OIDC_ISSUER: str = os.environ.get("OIDC_ISSUER", "")
|
OIDC_ISSUER: str = os.environ.get("OIDC_ISSUER", "")
|
||||||
OIDC_CLIENT_ID: str = os.environ.get("OIDC_CLIENT_ID", "")
|
OIDC_CLIENT_ID: str = os.environ.get("OIDC_CLIENT_ID", "")
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
Auth uses the existing `api_keys` table: a Bearer token in the Authorization
|
Auth uses the existing `api_keys` table: a Bearer token in the Authorization
|
||||||
header resolves to a user, and every tool call acts on that user's data only.
|
header resolves to a user, and every tool call acts on that user's data only.
|
||||||
"""
|
"""
|
||||||
from fabledassistant.mcp.server import build_mcp_server, mount_mcp
|
from scribe.mcp.server import build_mcp_server, mount_mcp
|
||||||
|
|
||||||
__all__ = ["build_mcp_server", "mount_mcp"]
|
__all__ = ["build_mcp_server", "mount_mcp"]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""MCP-side Bearer token resolution. Reuses the existing api_keys infrastructure."""
|
"""MCP-side Bearer token resolution. Reuses the existing api_keys infrastructure."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fabledassistant.services.api_keys import lookup_key
|
from scribe.services.api_keys import lookup_key
|
||||||
|
|
||||||
|
|
||||||
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
|
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user